О модуле

Sub-package плагина Inventory (не отдельный плагин) — двусторонняя синхронизация остатков материалов монтажников с 1С.

Направление Когда Что происходит

1С → ERP

Для HS и OData — по расписанию (ImportPoller) и вручную по кнопке; для File — загрузкой файла (см. Импорт остатков)

Импорт остатков склада монтажника → pending → подтверждение хозяином склада → баланс

ERP → 1С

Для HS — автоматически, при переходе процесса в статус СОГЛАСОВАНО; для File — вручную, скачиванием CSV и подтверждением; для OData — не передаётся вообще, только локальное списание в ERP (см. Экспорт списания при СОГЛАСОВАНО)

Экспорт списания материалов, зарезервированных по процессу

Резервирование материалов — только внутренняя операция ERP, в 1С не передаётся ни в каком виде. 1С видит только финальное списание после согласования (и то лишь по протоколам с каналом передачи — HS/File).

Протоколы обмена

Способ доставки данных туда-обратно настраивается per-instance (поле protocol у inventoru_sync1c_connector) — так решается проблема "у клиента нет своего 1С-программиста": основная бизнес-логика (очереди inventoru_sync1c_import_pending/inventoru_sync1c_export_queue, append-only движения) одна и та же для всех протоколов, отличается только то, как остатки/списания физически попадают туда-обратно.

Протокол Требует от 1С Когда использовать

hs (по умолчанию)

Кастомный HTTP-сервис (/hs/<ИМЯ СЕРВИСА>/*), пишет 1С-программист под конкретную базу

Есть свой 1С-программист — полностью автоматический обмен в обе стороны

file

Ничего — только штатный отчёт 1С в CSV и ручное проведение

Нет 1С-программиста (большинство небольших клиентов) — см. [setup-protocol-file]

odata

Ничего программного — только публикация базы на веб-сервере со стандартным OData-интерфейсом (/odata/standard.odata)

Нет 1С-программиста, но есть возможность опубликовать базу — автоматический импорт остатков и штрихкодов без кода на стороне 1С; экспорт списаний в 1С по этому протоколу невозможен (read-only), см. Протокол odata — импорт без кода на стороне 1С

epf (зарезервировано, не реализовано)

Ничего программного — готовая внешняя обработка (.epf), один раз собирается BGERP/подрядчиком под типовую конфигурацию и раздаётся клиентам

Планируется как промежуточный вариант — автоматизация без необходимости публиковать веб-сервис на стороне 1С

Настройка

Инстанс 1С

Администрирование → Инвентарь → Синхронизация с 1С → Инстанции. Один инстанс = одно подключение к одной базе 1С:

Поле Назначение

title

Название для админки

protocol

hs, file или odata — см. Протоколы обмена

api_url

Для hsкорень HTTP-сервиса вместе с его именем: http://<HOST>/<DATABASE>/hs/<ИМЯ СЕРВИСА>. Плагин дописывает только имя метода (/stocks, /writeoff), поэтому имя сервиса — часть этого адреса. Для odata — базовый URL стандартного интерфейса, обычно http://<HOST>/<DATABASE>/odata/standard.odata

username/password

HTTP Basic Auth — для hs и odata

item_ext_id_param_id

id text-параметра номенклатуры, где хранится product_id (код/GUID номенклатуры в 1С). Ключевое поле, см. предупреждение ниже

store_ext_id_param_id

id text-параметра склада, где хранится идентификатор склада в 1С. Именно он уходит при экспорте списания — см. Каким идентификатором называется склад при экспорте

config

Свободные key=value-настройки построчно — odata. (см. Протокол odata — импорт без кода на стороне 1С), csv. (см. [setup-protocol-file]), import.itemTypeId

Не задан item_ext_id_param_id — не работает сопоставление. Позиции ищутся по значению этого параметра и больше никак: при нуле getIdByExtIdParam всегда отвечает «не найдено», поэтому каждый импорт создаёт номенклатуру заново — повторная загрузка того же файла даёт полный комплект дублей. Тем же нулём ломается и экспорт: назвать позицию на языке 1С нечем, все строки пропускаются, и выгрузка списания приходит пустой.

Оба симптома тихие — ни ошибки, ни предупреждения на экране. Заводя коннектор, параметр внешнего кода задают первым; если номенклатура уже импортирована без него, значения нужно проставить разово, иначе следующий импорт её не узнает.

Кнопка Test connection (instance:testConnection) доступна для протоколов hs и odata: для hs дёргает /stocks и смотрит на HTTP-статус, для odata запрашивает одну строку балансовой таблицы ($top=1 по odata.balancePath) — заодно проверяет и корректность имени регистра, не только доступность/авторизацию.

Чем склад ERP называет себя в 1С

Идентичность склада описывается параметрами самого склада; коннектор называет, в каких параметрах она лежит:

Настройка коннектора Что в параметре

store_ext_id_param_id (поле карточки инстанса)

Код склада в 1С. Уходит при экспорте списания — см. Каким идентификатором называется склад при экспорте

store.guidParamId

GUID склада 1С: подставляется в $filter при импорте по odata и определяет, к какому инстансу склад относится. Не задан — берётся параметр внешнего кода

store.nameParamId

Имя склада в 1С — по нему 1С ищет склад при импорте по hs

store.locationParamId

Локация/объект, если база 1С их различает

hs.stocksPath

Путь метода остатков, по умолчанию /stocks — дописывается к api_url

hs.writeoffPath

Путь метода списания, по умолчанию /writeoff

Склад участвует в обмене с инстансом, если у него заполнен параметр GUID этого инстанса. Отдельного списка складов нет и заводить его не нужно: заполненный параметр и есть согласие на обмен, а пустой — отказ. Для двух баз 1С заводятся два параметра.

Таблица inventoru_sync1c_engineer_warehouse (админка → Маппинг) осталась, но заводить в ней записи руками больше не требуется: строка создаётся при первом обращении к складу и обновляется из параметров при каждом импорте. На неё ссылаются лог импорта и строки ожидающих поступлений, поэтому удалять строку с непроведёнными поступлениями система не даёт.

Склад, у которого нет ни GUID (для odata), ни имени (для hs), пропускается, а не опрашивается: пустой ответ 1С означал бы «на складе ничего нет», и импорт предложил бы обнулить все его остатки.

Конфигурация плагина

Тот же config_global, что и общий inventoru:* (см. Inventory). Префикс sync1c: при переименовании плагина в inventoru не менялся — эти ключи мигрировать не нужно. Отдельного ключа включения у sync1c нет — модуль живёт внутри плагина и включается вместе с inventoru:enable=1.

# Типы процессов, для которых включена интеграция (через запятую)
sync1c:processTypeIds=45

# Для каждого типа — id статуса, при входе в который отправляется списание
sync1c:processType.45.statusWriteoff=14

# Максимум попыток отправки при ошибке (после — status=failed, видно в админке; по умолчанию 3)
sync1c:export.maxAttempts=3

# Алярм админу (alarm.mail) после N подряд неуспешных импортов одного инстанса; 0 — отключить
sync1c:import.alarmConsecutiveErrors=3

Настройки конкретного коннектора — не здесь, а в его карточке (поле Конфигурация): параметры идентичности склада (Чем склад ERP называет себя в 1С), тип создаваемой номенклатуры и разбор CSV ([setup-protocol-file]), имена объектов OData (Протокол odata — импорт без кода на стороне 1С).

Планировщик

Два таска в bgerp.properties (классы — в пакете org.bgerp.plugin.inventoru.sync1c.exec):

scheduler.task.sync1c_import.class=org.bgerp.plugin.inventoru.sync1c.exec.ImportPoller
scheduler.task.sync1c_import.minutes=*/30

scheduler.task.sync1c_export.class=org.bgerp.plugin.inventoru.sync1c.exec.ExportPoller
scheduler.task.sync1c_export.minutes=*/1
  • ImportPoller обходит все включённые инстансы кроме протокола file (файл грузится только вручную): для каждого маппинга монтажник↔склад запускает импорт остатков (ImportService.syncEngineerWarehouse(), для odata — только маппинги с реальным GUID склада), затем для OData-инстансов с настроенным odata.barcodePath — импорт регистра штрихкодов (см. Импорт штрихкодов). При серии подряд неуспешных импортов инстанса шлёт алярм (sync1c:import.alarmConsecutiveErrors).

  • ExportPoller забирает только pending-записи инстансов с protocol='hs'file подтверждается вручную (см. Экспорт списания при СОГЛАСОВАНО), у odata pending-записей не бывает вовсе (см. Экспорт при OData — локальное списание без отправки). При исчерпании sync1c:export.maxAttempts — алярм.

Импорт остатков

Протокол hs — по расписанию (ImportPoller) или вручную: Администрирование → Синхронизация с 1С → Инстанции → Импортировать (instance:importNow), по конкретному маппингу монтажник↔склад (ewId). Запускает ImportService.syncEngineerWarehouse(): запрашивает /stocks, при первом обращении создаёт склад и недостающие позиции номенклатуры (по product_id/warehouse_id), пишет расхождения в inventoru_sync1c_import_pending — баланс не трогается до явного подтверждения (см. Импорт остатков — с подтверждением расхождений, про автоподтверждение совпадений — Импорт v2 lite — автоподтверждение совпадений).

Протокол odata — тот же syncEngineerWarehouse(), но остатки читаются из стандартного OData-интерфейса с $filter по GUID склада маппинга; маппинги без реального GUID (manual:* от ручных загрузок) пропускаются — без фильтра запрос вернул бы остатки ВСЕЙ базы и предложил их как pending одного склада. Плюс ручной импорт с превью — см. Протокол odata — импорт без кода на стороне 1С.

Протокол file — путь короче, чем у hs, и не требует заранее заведённого маппинга монтажник↔склад: файл → склад → превью → подтверждение.

Точки входа (любая ведёт на одну и ту же форму importFileForm):

  • Карточка склада (Инвентарь → Склады → карточка склада, раздел «Stock on hand» → кнопка Upload CSV) — склад уже предвыбран.

  • Inventory → Stock Movements → кнопка Upload CSV — склад предвыбран, если он выбран в фильтре.

  • Administration → Inventory → 1C Sync — раздел для настройки самой интеграции (инстансы, маппинг, логи); кнопки загрузки файла здесь намеренно нет.

Дальше — три шага:

  1. Выбор склада и файла (importFileFormimport_file_upload.jsp) — инстанс 1С автовыбирается, если он один (при нескольких — выпадающий список); список складов отфильтрован по доступу вызывающего (см. доступ к складам) — обычный пользователь видит только свои склады, полный админ — все.

  2. Превью (importFilePreview, read-only, ничего не пишет в БД) — парсит CSV, резолвит каждую строку по product_id (внешний ID номенклатуры), показывает таблицу: чекбокс принять/отклонить строку, найденное наименование (или «новая позиция: …​», если товар с таким product_id в ERP ещё не заведён), редактируемое поле «Quantity».

  3. Подтверждение (importFileConfirm) — принимает только отмеченные строки, резолвит или создаёт техническую связку склада (с синтетическим warehouse_id вида manual:<STORE_ID>, если у склада не заполнен параметр GUID — см. Чем склад ERP называет себя в 1С), пишет в inventoru_sync1c_import_pending — дальше всё как у протокола hs (см. Импорт остатков — с подтверждением расхождений).

Формат CSV — заголовок обязателен, порядок колонок не важен (ищутся по имени), лишние колонки игнорируются:

product_id,product_name,quantity
d776ab8b-ca60-11ee-bba6-f8f082350659,Кабель ВОК 8,15.000

Имена колонок

Обязательны три: идентификатор позиции, наименование и количество. Каждая ищется по нескольким принятым именам, поэтому типовая выгрузка 1С грузится без настройки:

Что Принимаемые имена

Идентификатор

product_id, Код, Артикул, GUID

Наименование

product_name, Наименование, Номенклатура, Товар

Количество

quantity, Остаток, Количество, Кол-во

Характеристика (необяз.)

characteristic, Характеристика, Характеристика номенклатуры

Серийный номер (необяз.)

serial, Серийный номер, Серия, Серия номенклатуры

Вид номенклатуры (необяз.)

kind, Вид номенклатуры, Вид — раскладка по типам, см. ниже

Если в конкретной выгрузке колонка называется иначе — имя задаётся в конфигурации коннектора:

csv.itemIdField=Ном
csv.itemNameField=Что это
csv.qtyField=Сколько

Когда обязательной колонки нет, сообщение об ошибке перечисляет ожидаемые имена и ключ для переопределения — чтобы кладовщик с непривычным файлом видел, чего не хватает, а не просто отказ.

Как читается количество. Дробная часть — и через точку, и через запятую; разряды могут быть разделены пробелом, в том числе неразрывным, как их пишет Excel (1 234,51234.5). Ячейка, которая числом не является, называет номер своей строки и своё содержимое — файл длинный, оператор нет. Пустая ячейка — это ноль, а не пропуск: файл задаёт итоговый остаток, поэтому пустое количество означает «позиции больше нет», и такая строка проводится, обнуляя остаток в ERP.

Остатки хранятся с точностью до трёх знаков после запятой (DECIMAL(12,3)). Количество с большей точностью округляется молча: 2.85632.856, 0.00040. Для позиций, которые считают в метрах или килограммах с четырьмя знаками, это стоит держать в голове.

Каким идентификатором называется склад при экспорте

Списание уходит в 1С с идентификатором склада, и берётся он так:

  1. у коннектора задан store_ext_id_param_id и у склада этот параметр заполнен — уходит его значение;

  2. параметр задан, а у склада пусто — запись экспорта не отправляется, а помечается ошибкой «У склада не заполнен параметр внешнего кода». Тихо отправить внутренний идентификатор нельзя: принимающая сторона не может его разобрать и обычно подставляет какой-нибудь свой склад — тогда списание запишется не туда и вернётся как успешное;

  3. параметр у коннектора не задан вовсе — уходит то, что лежит в записи очереди: настоящий GUID из маппинга либо синтетическое manual:<id склада>, если запись создана ручной загрузкой файла.

Значение параметра выигрывает у идентификатора из маппинга — это осознанная настройка администратора, и единственное, что масштабируется дальше одного склада. Расхождение пишется в лог уровня INFO, чтобы подмена настоящего GUID не осталась незамеченной.

Разрешённый идентификатор сохраняется в самой записи очереди, поэтому экран очереди и выгрузка CSV показывают то, что действительно ушло. Заполнение параметра задним числом действует и на уже накопленные записи: pending/failed переотправятся с новым значением.

Импорт остатков этот параметр не заполняет — ни файловый, ни OData. Значения проставляются руками в карточке склада, по одному на склад.

Тип создаваемой номенклатуры. Позиция, которой импорт не задал тип, не показывает ни одного параметра на карточке — набор параметров берётся из типа. Код 1С при этом записывается и экспортом читается, но человеку не виден. В файле 1С брать тип неоткуда («Вид номенклатуры» и «Входит в группу» — не наша иерархия типов), поэтому его называет коннектор:

import.itemTypeId=3

Пусто или 0 — позиция создаётся без типа (прежнее поведение). Тип должен включать параметр внешнего кода в своём param.ids, иначе артикул снова будет невидим.

Один тип на все позиции — стартовая настройка: тип несёт ещё и вид учёта, а он у инструмента и у кабеля разный. Раскладку задаёт карта по колонке «Вид номенклатуры» выгрузки:

import.itemTypeId=3
import.itemType.Материалы=4
import.itemType.Товары=5
import.itemType.Инвентарь и хозяйственные принадлежности=6
import.itemType.Спецодежда=7
import.itemType.Оборудование к установке=8

Ключ — значение колонки как его пишет 1С; пробелы и кириллица допустимы. Сравнение не различает регистр и обрезает пробелы вокруг значения, но пробел перед знаком = войдёт в ключ и совпадения не даст. Незнакомый вид или отсутствие колонки — берётся import.itemTypeId.

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

Вид номенклатуры приходит из файла (колонка) и из HTTP-сервиса (поле kind в ответе /stocks), если сервис его отдаёт. OData его не передаёт — там работает только import.itemTypeId.

Один тип на все создаваемые позиции — это стартовая настройка, а не конечная: тип несёт ещё и вид учёта (расходник или основное средство), а он у инструмента и у кабеля разный. Разумная раскладка выводится из колонки выгрузки «Вид номенклатуры» — в типовой выгрузке 1С это Материалы, Товары, Инвентарь и хозяйственные принадлежности, Спецодежда, Оборудование к установке; первые две и последняя — расходники, инвентарь и спецодежда — основные средства, которые не резервируются под процесс, а передаются между складами. Импорт эту колонку не читает (в ERP нет соответствия видам 1С), поэтому типы заводят руками и раскладывают позиции по ним разово.

Строка «Итого». Выгрузка 1С обычно заканчивается итоговой строкой: наименование и сумма без кода. По умолчанию она попадает в предпросмотр как обычная строка — снять с неё галку дело одного клика, и только оператор знает, итог перед ним или позиция. Если файлы этой базы всегда приходят с итогом, его можно отбрасывать сразу:

csv.skipLastLine=1

Позиции с поштучным учётом (serial.tracked у типа номенклатуры, см. Inventory) этим импортом загружать нельзя: файл задаёт итоговое количество, а остаток такой позиции обязан совпадать с числом устройств на складе. В предпросмотре такие строки помечаются предупреждением; загружать их следует импортом оборудования, где ключом идёт серийный номер.

Пример файла — example_stocks.csv рядом с этим документом.

Разделитель и кодировка

Определяются по содержимому файла, настраивать обычно не требуется:

  • Разделитель — по первой (заголовочной) строке считаются кандидаты ;, , и табуляция, побеждает самый частый; вхождения внутри кавычек не считаются. Если ни одного нет (файл в одну колонку) — запятая.

  • Кодировка — BOM решает сразу; иначе файл пробуется как строгий UTF-8, и если он им не является — читается как Windows-1251. Чистый ASCII разбирается как UTF-8, для него обе кодировки совпадают.

Это не косметика: типовая выгрузка 1С — точка с запятой, потому что запятая в русской локали занята под десятичный разделитель (15,000), и нередко Windows-1251, потому что так отдаёт Excel. Файл с разделителем-точкой-с-запятой, прочитанный как CSV с запятыми, разберётся в одну колонку, и импорт пожалуется на отсутствие обязательной колонки, хотя файл корректен.

Если определение всё же ошиблось, оно перекрывается в конфигурации коннектора:

csv.delimiter=;
csv.encoding=windows-1251

csv.delimiter=tab — табуляция. Пустое значение (ключа нет) — определять автоматически.

Количество — это ИТОГОВЫЙ (абсолютный) остаток на складе на момент выгрузки, а не добавка к тому, что уже есть в ERP. Как и у протоколов hs/odata (см. POST <api_url>/stocks — получение остатков) — источник всегда описывает полный снимок, а не дельту. Если в файле 15.000, а в ERP уже было 10 — после подтверждения станет 15 (не 25). Колонка «Diff» в «Ожидающих поступлениях» (см. Импорт остатков — с подтверждением расхождений) как раз показывает фактическое изменение, чтобы не перепутать итог с добавкой — то же предупреждение показано прямо на форме загрузки.

Кладовщик формирует такой файл штатным отчётом 1С (или выгрузкой из консоли/обработки) — сам ERP не диктует, как именно 1С сформирует данные, только формат файла. Число — с точкой в качестве разделителя (при заковыченном поле "15,000" парсер примет и запятую); строго проверяется на клиенте перед отправкой — нечисловое значение блокирует подтверждение целиком, а не тихо обрезается. Файл кодируется в UTF-8 (с BOM или без — оба варианта читаются).

ImportService.importFromRows() — то же самое ядро (upsert item/store, запись в inventoru_sync1c_import_pending), что и у остальных протоколов, только источник строк другой — не HTTP, а разобранный CsvUtil.parse() файл. Дальнейшее подтверждение (см. Импорт остатков — с подтверждением расхождений) не отличается вообще.

Кто может загружать CSV — доступ на уровне склада, не только полный админ

Загрузка CSV и последующее подтверждение в «Ожидающих поступлениях» — это ДВЕ отдельные точки, обе проверяют доступ к конкретному складу так же, как остальные действия плагина (store_access ∪ владелец склада ∪ «группа редактирования», см. доступ к складам), не только «есть ли право на действие вообще»:

  • AdminSyncAction.importFileForm/importFilePreview/importFileConfirm (и OData-аналоги odataForm/odataPreview) — фильтруют список складов и проверяют storeId из запроса на сервере (не только скрытием в UI).

  • UserImportPendingAction (страница «Pending incoming») — видит и может провести/отклонить запись любой, у кого есть доступ к складу этой записи, а не только исходный владелец склада (EngineerWarehouse.userId). Это позволяет нескольким «кладовщикам» делить обязанности по одному складу.

  • Админские importApply/importApplyAll/importReject и exportQueue* проверяют то же самое — defense-in-depth на случай, если scoped-набор прав по ошибке включит их без instance:null.

Права на сами три действия загрузки (importFileForm/importFilePreview/importFileConfirm) выдаются в наборах прав независимо от остальной админки sync1c (instance:null и т.д.) — можно выдать кладовщику ТОЛЬКО загрузку файла, не открывая ему весь раздел «1C Sync».

Протокол odata — импорт без кода на стороне 1С

ERP сам читает стандартный OData-интерфейс 1С (/odata/standard.odata) — от 1С требуется только публикация базы на веб-сервере, никакого кастомного кода. Плата за это: имена объектов метаданных (регистр остатков, справочник, поля) различаются между конфигурациями 1С (УТ/УНФ/КА/ERP/БП), поэтому задаются настройками в поле «Config» коннектора (ODataClient, дефолты — под типовую УНФ/УТ):

# Виртуальная таблица остатков регистра накопления
odata.balancePath=AccumulationRegister_ЗапасыНаСкладах/Balance()
# Справочник номенклатуры — резолв GUID → наименование; пусто = имена не резолвятся (останется GUID)
odata.catalogPath=Catalog_Номенклатура
# Поля строки остатков: GUID номенклатуры, количество, GUID склада
odata.itemKeyField=Номенклатура_Key
odata.qtyField=КоличествоBalance
odata.warehouseKeyField=Склад_Key

Остатки читаются двумя GET-запросами: балансовая таблица (с $filter по GUID склада) + справочник номенклатуры для имён; строки одного товара с разными ГТД/организациями агрегируются по product_id (иначе UNIQUE-ключ pending молча оставил бы количество только последней строки). Дальше — общий протокол-агностичный importFromRows() и обычный флоу подтверждения.

Способы запуска импорта:

  • АвтоматическиImportPoller по расписанию, по каждому складу, у которого заполнен параметр GUID этого инстанса (см. Чем склад ERP называет себя в 1С); склад без GUID пропускается.

  • Вручную с превьюИнстанции → меню инстанса → Импорт остатков (OData) (instance:odataFormodataPreview): форма с odata-полями (предзаполняются из конфига коннектора), выбором склада ERP (отфильтрован по доступу вызывающего) и опциональным GUID склада 1С (строго валидируется по форме GUID — он подставляется в $filter-литерал). Превью read-only, ничего не пишет в БД; подтверждение отобранных строк идёт через тот же importFileConfirm, что и у протокола file, — проверки доступа и создание маппинга не дублируются.

Импорт штрихкодов

Тот же коннектор может импортировать регистр штрихкодов номенклатуры в inventoru_item_barcode (см. штрихкоды и скан):

# Пусто (по умолчанию) = импорт штрихкодов для коннектора выключен
odata.barcodePath=InformationRegister_ШтрихкодыНоменклатуры
odata.barcodeCodeField=Штрихкод
odata.barcodeItemKeyField=Номенклатура_Key

Сохраняются только штрихкоды, чей GUID номенклатуры резолвится в существующую позицию ERP через item_ext_id_param_id — незнакомые пропускаются (подхватятся следующим прогоном, после того как импорт остатков создаст позицию). Запуск: ImportPoller по расписанию или вручную — кнопка импорта штрихкодов на форме OData-импорта (instance:barcodesImportNow, возвращает число сохранённых привязок).

Экспорт при OData — локальное списание без отправки

OData-протокол read-only — канала передачи списания в 1С нет. Поэтому при входе процесса в writeoff-статус (ExportService.enqueue()) для складов OData-инстансов:

  • запись очереди экспорта создаётся сразу в статусе success (атомарно — конкурентный повторный вход не продублирует), без единого HTTP-вызова;

  • тут же создаются локальные writeoff-движения в ERP (баланс и резерв уменьшаются) — 1С и так мастер выданных документов, ERP нужно лишь собственное списание; следующий импорт остатков его подтвердит;

  • success-запись сохраняет обе привязанные к ней механики: блокировку вкладки «Materials» (hasSuccessExport()) и сторно при возврате процесса из согласованного статуса (reverseWriteoffMovements(), см. Сценарий «Возврат из Согласовано» — реверс уже отправленного списания) — они работают для OData так же, как для HS/File;

  • в админке Повторить для таких записей невозможен (разрешён только из pending/failedsuccess вернуть в pending нельзя ни для какого протокола: списание уже проведено, а для OData его и физически некуда переотправить), Delete запрещён для success/reversed — удаление стёрло бы единственный маркер блокировки вкладки «Materials» при живых writeoff-движениях в журнале.

Импорт v2 lite — автоподтверждение совпадений

importFromRows() не создаёт pending-запись на каждую строку ответа — только на реальные расхождения, чтобы не приучать владельцев складов жать «Apply all» вслепую:

  • 1С == ERP — количество из 1С совпадает с текущим inventoru_balance.quantity: pending не создаётся; если по этой позиции висело старое неподтверждённое предложение — оно удаляется как устаревшее (deleteIfPending).

  • 1С ≠ ERP — обычный upsert в pending, дальше стандартное подтверждение (см. Импорт остатков — с подтверждением расхождений).

  • Позиция пропала из ответа (обнуление, см. Обнуление остатков — позиция пропала из ответа) — pending с нулём создаётся только если текущий баланс ERP ненулевой; при уже нулевом балансе предлагать нечего — максимум чистится устаревший pending.

  • Для OData дополнительно: склады без GUID склада 1С пропускаются целиком (см. Чем склад ERP называет себя в 1С).

API-контракт (HTTP-сервис на стороне 1С)

Только для протокола hs (см. Протоколы обмена) — для file формат другой (см. [setup-protocol-file]), odata использует стандартный интерфейс 1С без контракта на стороне 1С (см. Протокол odata — импорт без кода на стороне 1С).

Кастомный HTTP-сервис (/hs/…​), реализуется 1С-разработчиками под конкретную базу. BGERP не знает и не зависит от версии/конфигурации 1С — только от этого контракта. Если несколько баз/версий 1С реализуют один и тот же контракт — код ERP менять не нужно, достаточно завести новый инстанс.

Общее

  • Базовый URL: http://<HOST>:<PORT>/<DATABASE>/hs/<ИМЯ СЕРВИСА> — значение поля api_url инстанса; имя сервиса выбирает тот, кто его пишет в 1С

  • Аутентификация: Authorization: Basic <base64(login:password)>

  • Content-Type: application/json, кодировка UTF-8

  • Формат ответа:

    { "data": [...], "error": false, "errorText": "" }

    При ошибке: "error": true, "errorText": "описание".

POST <api_url>/stocks — получение остатков

Запрос:

{ "warehouse_name": "Лопатин Владимир Владимирович", "location_name": "ст-ца Брюховецкая - ОМИПЛАТ" }

Запрос:

Поле Тип Значение

warehouse_name

строка

Имя склада в 1С — берётся из параметра склада, названного ключом store.nameParamId (см. Чем склад ERP называет себя в 1С). Пустое имя 1С обычно не находит, и ответ приходит пустым

location_name

строка

Локация/объект, если база их различает; иначе пустая строка

Ответ — массив data; поля на элемент:

Поле Тип Значение

product_id

строка

Код позиции в 1С — по нему ERP находит номенклатуру через параметр внешнего кода. Без него строка не сопоставляется

product_name

строка

Наименование; им создаётся позиция, которой в ERP ещё нет

stocks

число

Итоговый остаток на складе, не приход

kind

строка

Необязательно. Вид номенклатуры («Материалы», «Товары»…) — раскладывает создаваемые позиции по типам, см. Имена колонок

characteristic

строка

Необязательно. Характеристика позиции

serial

строка

Необязательно. Серийный номер

warehouse_id

строка

Идентификатор склада в 1С; читается только при создании склада из 1С

organization_id, organization_name, gtd_id, gtd_name, location, engineer, stocks_rntp

Принимаются и игнорируются, см. ниже

Что из этого ERP на самом деле использует. Импорт читает product_id (сопоставление позиции по параметру внешнего кода), product_name (наименование создаваемой позиции), stocks (итоговый остаток), warehouse_id (при создании склада из 1С) и, из файлового источника, характеристику, серийный номер и вид номенклатуры. Остальные поля контракта ERP принимает и игнорирует:

Поле Что с ним

stocks_rntp

Не читается нигде. Оставлено в контракте как исторический второй регистр остатков

engineer

Не читается: какие склады участвуют в обмене, ERP определяет параметрами склада, а не признаком из ответа

gtd_id, gtd_name

При импорте не читаются. В экспорте списания поле gtd_id присутствует в контракте, но ERP всегда шлёт null — партионный учёт по ГТД на стороне ERP не ведётся, поэтому и колонка gtd_id в выгрузке CSV всегда пуста

organization_id, organization_name

Не читаются: остатки в ERP не разделяются по организациям

Поля не удалены из контракта намеренно: 1С-сервисы у разных клиентов уже их отдают, и требовать переписывания ради чистоты ответа смысла нет.

Контракт по обнулившимся позициям (решено): если остаток по позиции стал равен нулю, она полностью пропадает из массива data — 1С не присылает её с stocks=0. Ответ на /stocks трактуется как ПОЛНЫЙ снимок остатков склада на момент запроса (не дельта), поэтому ERP сама считает исчезновение позиции из ответа сигналом "остаток обнулился" (см. Обнуление остатков — позиция пропала из ответа). Это верно для всех протоколов (hs/file/odata) — источник строк (HTTP-ответ, CSV, OData) не важен, обнуление считается по одному и тому же правилу.

data обязано быть JSON-массивом. Ответ, в котором data отсутствует, равно null или является объектом, считается ошибкой обмена: импорт пишет ошибку в лог и в журнал импортов и ничего не предлагает. Так сделано намеренно: пустой массив означает «на складе ничего нет», и импорт на него предлагает обнулить остатки склада — принять за это неразобранный ответ нельзя.

error читается как boolean. Значение true — ошибка, текст берётся из errorText. Строка вместо boolean ("error": "Склад не найден") тоже считается ошибкой, а не успехом, но полагаться на это не стоит: в контракте поле булево.

Имена полей чувствительны, лишние игнорируются. Разбор идёт по точным именам из таблиц; поле, названное иначе, молча становится пустым (@JsonIgnoreProperties(ignoreUnknown=true)).

POST <api_url>/writeoff — списание материалов

Вызывается ERP автоматически при переходе процесса в статус СОГЛАСОВАНО (sync1c:processType.<ID>.statusWriteoff).

Запрос:

{
  "process_id": 12345,
  "date": "2026-02-26",
  "warehouse_id": "228a5e7e-c13c-11f0-b829-005056ba50ab",
  "items": [
    { "product_id": "d776ab8b-ca60-11ee-bba6-f8f082350659", "gtd_id": "07cd97c7-019a-11f1-b829-005056ba50ab", "quantity": 3.0 },
    { "product_id": "a1b2c3d4-0000-0000-0000-000000000001", "quantity": 10.0 }
  ]
}

Поля запроса:

Поле Тип Значение

process_id

число

Процесс ERP — внешняя ссылка для документа 1С и ключ идемпотентности

date

строка ГГГГ-ММ-ДД

Дата отправки (текущая дата ERP), не дата резервирования

warehouse_id

строка

Идентификатор склада — значение параметра внешнего кода склада, см. Каким идентификатором называется склад при экспорте

items[].product_id

строка

Код позиции в 1С — значение параметра внешнего кода номенклатуры

items[].gtd_id

строка

Необязателен и физически отсутствует в JSON, когда пуст (@JsonInclude(NON_NULL)); ERP партионный учёт не ведёт и всегда опускает его

items[].quantity

число

Количество; дробная часть через точку

Ответ — тот же общий формат, "data" при успехе игнорируется целиком: ERP смотрит только на error/errorText и на HTTP-статус.

Ответ без error означает для ERP «списание в 1С проведено»: она создаёт движения списания, и остаток уменьшается. Если сервис на этом этапе документ ещё не создаёт (режим проверки сопоставления), отвечать успехом нельзя — иначе остатки разойдутся, и следующий импорт остатков предложит вернуть списанное обратно. На время такой отладки сервис должен отвечать error: true с пояснением, либо тип процесса не должен быть указан в sync1c:processTypeIds.

Требуется идемпотентность на стороне 1С: повторный вызов с тем же process_id должен возвращать успех («уже списано»), не ошибку — ERP автоматически повторяет отправку при сбоях (см. Экспорт списания при СОГЛАСОВАНО).

Использование

Импорт остатков — с подтверждением расхождений

1С присылает АБСОЛЮТНЫЕ остатки (не дельты). Импорт (см. Импорт остатков) пишет расхождения не сразу в баланс, а в inventoru_sync1c_import_pending со статусом pending (совпадения автоподтверждаются молча, см. Импорт v2 lite — автоподтверждение совпадений) — кто-то с доступом к складу должен явно подтвердить или отклонить каждую позицию:

  • Пункт меню «Incoming» (пользователь, /user/plugin/inventoru/import_pending) — список pending-позиций для складов, к которым у текущего пользователя есть доступ: он владелец склада (store.user_id), у него есть индивидуальная запись inventoru_store_access, или он состоит в группе, указанной у склада как «группа редактирования» (edit_group_id) — см. доступ к складам. Так несколько «кладовщиков» могут делить обязанности по одному складу. Счётчик ожидающих позиций виден и на экране «My warehouse» (см. Мой склад).

  • Post — записывает абсолютное значение в inventoru_balance.quantity (не трогая reserved), плюс пишет строку в inventoru_movement (type=adjustment, количество = разница «стало минус было», автор — кто нажал «Post») — см. Паттерн append-only + ref_movement_id и Движения ТМЦ в основном документе. Так изменение видно в уже привычной ленте движений склада.

  • Apply all — массово, то же самое для каждой позиции.

  • Reject — помечает rejected, баланс не трогается, движение не пишется.

И apply, и reject дополнительно проверяют на сервере, что конкретная pending-запись действительно относится к складу, доступному вызывающему (не только видна в списке) — id записи недостаточно, чтобы провести/отклонить чужую.

Повторный импорт той же позиции (уникальный ключ ew_id + item_id) сбрасывает её обратно в pending, независимо от прежнего статуса (applied/rejected) — сброс идёт при каждом новом импорте, даже если предыдущая загрузка ещё не была проведена (новое количество полностью затирает предыдущее ожидающее значение, они не складываются). Это верно для всех протоколов — источник строк (HTTP-ответ, CSV-файл, OData) не влияет на дальнейшую обработку.

Админка (1C Sync → Pending incoming) даёт тот же функционал для любого инстанса (для полного админа — без ограничения по складам), плюс Import log — история запусков со статусом/количеством позиций/текстом ошибки (но без разбивки по товарам — за подробностями по конкретному товару смотреть ленту «Stock Movements» склада, см. выше).

Обнуление остатков — позиция пропала из ответа

Раз 1С не присылает обнулившиеся позиции явно (см. POST <api_url>/stocks — получение остатков), ERP сама детектирует исчезновение: ImportService.importFromRows() после обработки всех полученных строк вычисляет ImportPendingDAO.getKnownItemIds(ewId, storeId) — объединение (a) позиций с quantity > 0 в inventoru_balance для этого склада и (b) позиций, всё ещё ожидающих подтверждения (status='pending') по этому маппингу — и для каждой, отсутствующей в текущем ответе и с ненулевым балансом ERP (см. Импорт v2 lite — автоподтверждение совпадений), вызывает тот же upsert(…​) с quantity=0. Дальше — обычный флоу подтверждения: обнулённая позиция появляется в «Поступлениях» как pending-запись с количеством 0, хозяин склада жмёт «Post» (обнуляет inventoru_balance.quantity) или «Reject» (считает, что 1С ошиблась/позиция ещё физически есть).

Работает и для полностью пустого ответа (data: []) — в этом случае обнуляются вообще все позиции, когда-либо известные для этого склада. Единственный случай, когда обнуление не считается: самый первый импорт склада пришёл пустым (склад ещё не создан, ew.storeId=0) — тут обнулять нечего.

Правило одинаково для всех протоколов (hs/file/odata) — реализовано в протокол-агностичном importFromRows(), а не в HTTP-специфичном коде.

Экспорт списания при СОГЛАСОВАНО

  1. Процесс переходит в статус, указанный в sync1c:processType.<ID>.statusWriteoffSync1cExportListener ставит запись(и) в очередь inventoru_sync1c_export_queue — по одной на каждый склад, с которого процесс резервировал (upsert по process_id + instance_id + store_id — повторный вход в статус сбрасывает существующие записи обратно в pending). Если несколько складов процесса замаплены на один и тот же 1С-инстанс, каждый получает свою запись и свой отдельный вызов /writeoff, а не смешивается в один. Это общий шаг для всех протоколов; для OData запись сразу становится success и списание проводится локально — см. Экспорт при OData — локальное списание без отправки.

  2. Протокол hsExportPoller (раз в минуту) забирает pending-записи инстансов с protocol='hs', отправляет /writeoff со всеми активными резервами процесса с этого склада, при успехе создаёт writeoff-движения (уменьшают quantity и reserved) и помечает запись success. При ошибке — failed (до sync1c:export.maxAttempts попыток, видно в админке с текстом ошибки, кнопка Retry; на исчерпании попыток — алярм админу). Перед отправкой поллер перепроверяет, что запись всё ещё pending — если её успели отменить конкурентно (см. ниже), отправка пропускается.

  3. Протокол fileExportPoller эти записи не трогает вообще (см. Планировщик). В Export Queue доступна кнопка Download CSV (queue_id,process_id,warehouse_id,product_id,gtd_id,quantity, по строке на позицию — один queue_id может дать несколько строк) — кладовщик проводит списание в 1С вручную по этому файлу, затем в ERP жмёт Confirm (по одной записи) или Confirm all (все pending записи инстанса разом). Подтверждение делает то же самое, что успешный ExportPoller: создаёт writeoff-движения и переводит запись в success — retry/attempts не применяются, т.к. нет HTTP-вызова, который мог бы упасть. Запись атомарно «захватывается» (условный UPDATE из pending) до создания движений — двойной клик или гонка с «Confirm all» не даст двойного списания.

Каждая попытка отправки/подтверждения (hs/file) пишет строку в inventoru_sync1c_export_log (1C Sync → Export log, instance:exportLog) — instance/process/queue id, время начала/завершения, количество позиций, статус (running/ok/error) и текст ошибки. Аналог import_log, но для экспорта; полезно для диагностики "почему списание не ушло" без необходимости лезть в bgerp.log.

Матрица статусов очереди экспорта

Статус Значение

pending

Ждёт отправки (или переотправки после сброса) — не бывает у OData-инстансов

success

Списание проведено: для hs/file — отправлено/подтверждено в 1С, для odata — локальное списание в ERP без отправки; ERP-движения writeoff созданы

failed

Ошибка при отправке (текст — last_error), будет повторено поллером до лимита попыток

cancelled

Процесс вернули «на доработку» до фактической отправки — см. Сценарий «На доработку» — отмена ещё не отправленного экспорта

reversed

Списание было success, но процесс потом вернули из СОГЛАСОВАНО и списание реверснуто в ERP — см. Сценарий «Возврат из Согласовано» — реверс уже отправленного списания

Правила ручных действий в админке: Retry (exportQueueRetry) — только из pending/failed (success нельзя вернуть в pending: разблокировалась бы вкладка «Materials» при живом списании в журнале; cancelled/reversed терминальны — повторный вход в writeoff-статус создаёт свежую запись через enqueue()). Delete (exportQueueDelete) — запрещено для success/reversed (удаление стёрло бы единственный маркер hasSuccessExport(), блокирующий вкладку «Materials»).

Сценарий «На доработку» — отмена ещё не отправленного экспорта

Процесс вернули на доработку до того, как ExportPoller успел забрать pending-запись (окно — до минуты). Монтажник отменяет резерв (unreserve) — ExportQueueDAO.cancelPendingByProcessId() переводит запись pending → cancelled (записи success/failed не трогает). При повторном переходе в СОГЛАСОВАНО запись пересоздаётся штатным upsert, экспорт уходит заново.

Сценарий «Возврат из Согласовано» — реверс уже отправленного списания

Решение по продукту: количество материалов ведёт ERP, состояние 1С после реверса — не забота ERP («монтажник может делать что хочет со своим рюкзаком, дальнейшая сверка с 1С — на стороне 1С»).

Если процесс покидает writeoff-статус (в любую сторону, не обязательно «Новый»), а по нему уже есть success-запись экспорта — Sync1cExportListener вызывает ExportService.reverseWriteoffMovements():

  1. Для каждого ещё не реверснутого writeoff-движения процесса с ext_ref='sync1c' создаётся receipt-движение на ту же номенклатуру/склад/количество (НЕ return — тот дополнительно уменьшает reserved, что задело бы чужие резервы на этом складе/номенклатуре, никак не связанные с данным процессом).

  2. Все success-записи очереди экспорта процесса переводятся в reversed.

  3. Вкладка «Materials» автоматически разблокируется — блокировка по «уже экспортировано» смотрит только на status='success' (см. Inventory).

Реверс покрывает весь накопленный по процессу writeoff, а не только последнее движение, при этом уже скомпенсированные writeoff’ы (по ref_movement_id реверсного receipt) не реверсятся повторно — второй цикл «списание→возврат» того же процесса не рождает фантомный остаток. Работает одинаково для всех протоколов, включая локальные OData-списания (см. Экспорт при OData — локальное списание без отправки).

Без проверки на отрицательный баланс и без обращения к 1С за подтверждением — намеренно, по решению выше. receipt физически не может увести баланс в минус (только прибавляет), так что защита и не нужна.

Разработка

Структура файлов

src/org/bgerp/plugin/inventoru/sync1c/
├── Config.java                    — sync1c:processTypeIds, processType.<ID>.statusWriteoff,
│                                    export.maxAttempts, import.alarmConsecutiveErrors
├── action/
│   ├── AdminSyncAction.java       — /admin/plugin/inventoru/sync1c/instance (инстансы, маппинг, импорт/экспорт
│   │                                для всех протоколов): importFile* (file), odataForm/odataPreview/
│   │                                barcodesImportNow (odata), testConnection (hs+odata),
│   │                                exportQueue*/importLog/exportLog; getAccessRestriction() — та же
│   │                                схема доступа (store_access ∪ владелец ∪ edit_group_id),
│   │                                маркер "полный админ" — владение /admin/plugin/inventoru/sync1c/instance:null
│   └── UserImportPendingAction.java — /user/plugin/inventoru/import_pending;
│                                      getAccessibleStoreIds() (владелец ∪ store_access ∪ edit_group_id)
├── dao/
│   ├── InstanceDAO, EngineerWarehouseDAO, SyncLogDAO, ExportLogDAO
│   ├── ImportPendingDAO           — upsert/deleteIfPending/getBalanceMap/getKnownItemIds/apply/
│   │                                applyAll/reject; apply() пишет audit-строку в
│   │                                inventoru_movement (type=adjustment), см. <<usage-import>>
│   └── ExportQueueDAO             — add/addSuccess (OData)/getPendingByInstance/getPendingByProtocol/
│                                    isPending/finalizePendingStatus/hasSuccessExport/
│                                    cancelPendingByProcessId/markReversed
├── event/
│   └── Sync1cExportListener.java  — ProcessChangedEvent → enqueue() при входе в writeoff-статус,
│                                    reverseWriteoffMovements() при выходе с success-экспортом
├── exec/
│   ├── ImportPoller.java          — scheduler task: остатки (hs+odata) + штрихкоды (odata),
│   │                                алярм при серии ошибок
│   └── ExportPoller.java          — scheduler task, только protocol='hs' (getPendingByProtocol),
│                                    алярм при исчерпании попыток
├── service/
│   ├── Api1cClient.java           — HTTP-клиент (/stocks, /writeoff, testConnection), Basic Auth —
│   │                                только протокол hs
│   ├── ODataClient.java           — клиент /odata/standard.odata: fetchStocks (балансовая таблица +
│   │                                справочник имён), fetchBarcodes (регистр штрихкодов),
│   │                                testConnection ($top=1); ошибки НЕ глотаются — админ в превью
│   │                                должен видеть, что пошло не так
│   ├── ImportService.java         — syncEngineerWarehouse() (протокол-ветвление hs/odata),
│   │                                importFromRows() (протокол-агностичное ядро: агрегация по
│   │                                product_id, автоподтверждение совпадений, upsert item/store,
│   │                                pending, зануление), importBarcodes()
│   ├── ExportService.java         — enqueue (с OData-веткой addSuccess+локальное списание)/
│   │                                sendExportQueue/buildWriteoffItems (переиспользуется
│   │                                CSV-экспортом)/createWriteoffMovements/reverseWriteoffMovements
│   └── CsvUtil.java               — минимальный RFC4180 parse/write, автоопределение
│                                    разделителя и кодировки, протокол file
└── model/
    ├── Sync1cConnector            — protocol (hs/file/odata, "epf" зарезервирован) + геттеры
    │                                odata.* настроек с дефолтами; EngineerWarehouse, ImportLog
    ├── ImportPending, ExportQueue (STATUS_PENDING/SUCCESS/FAILED/CANCELLED/REVERSED), ExportLog
    └── Api1cStock                 — @JsonIgnoreProperties(ignoreUnknown=true), маппинг строки
                                     остатков любого протокола

webapps/WEB-INF/jspf/user/plugin/inventoru/
├── sync1c/import_pending.jsp      — «Pending incoming» (доступ по store_access/владелец/edit_group_id)
└── store/card.jsp                 — кнопка "Загрузить CSV" в разделе "Остатки на складе"

webapps/WEB-INF/jspf/admin/plugin/inventoru/sync1c/
├── instance_list.jsp, instance_edit.jsp (+ поля protocol и config c odata.*-ключами)
├── mapping_list.jsp, mapping_edit.jsp
├── import_file_upload.jsp         — файл→склад→превью→подтверждение (протокол file)
├── odata_import.jsp               — форма/превью OData-импорта (см. <<setup-protocol-odata>>)
├── import_pending.jsp, import_log.jsp
├── export_queue.jsp               — + "Скачать CSV"/"Подтвердить"/"Подтвердить всё" для протокола file
└── export_log.jsp                 — история попыток экспорта, см. <<dev-db>>

Таблицы БД

Таблица Назначение

inventoru_sync1c_connector

Подключения к базам 1С (url, credentials, protocol, ext_id param ids, config с odata.*)

inventoru_sync1c_engineer_warehouse

Техническая связка склад ERP ↔ склад 1С; ведётся кодом из параметров склада, руками не заполняется (см. Чем склад ERP называет себя в 1С)

inventoru_sync1c_import_pending

Расхождения остатков от 1С, ждущие подтверждения (UNIQUE ew_id, item_id)

inventoru_sync1c_import_log

История запусков импорта

inventoru_sync1c_export_queue

Очередь экспорта списаний — одна запись на каждую пару (процесс, склад), даже если несколько складов процесса замаплены на один и тот же 1С-инстанс (UNIQUE process_id, instance_id, store_id), status — VARCHAR(20), см. Экспорт списания при СОГЛАСОВАНО

inventoru_sync1c_export_log

История попыток отправки списания — одна запись на каждый прогон ExportPoller (протокол hs) или ручное "Подтвердить" (протокол file), см. Экспорт списания при СОГЛАСОВАНО

inventoru_item_barcode

Штрихкоды номенклатуры (наполняется OData-импортом, лежит в основном плагине)

Паттерн append-only + ref_movement_id

Как и весь inventoru_movement — записи только добавляются. Связь «это движение — следствие того движения» — через ref_movement_id:

«Активная» reserve-строка — та, на которую не ссылается ни один unreserve/writeoff (см. MovementDAO.getListByProcess). Отмена уже списанного резерва отдельно защищена — MovementDAO.isActiveReserve() отклоняет повторный/устаревший unreserve.

Отладка

grep "Sync1cExportListener\|ExportService\|ExportPoller" log/bgerp.log
grep "ImportService\|ImportPoller\|ODataClient" log/bgerp.log
-- Очередь экспорта конкретного процесса
SELECT * FROM inventoru_sync1c_export_queue WHERE process_id=?;

-- Pending-остатки монтажника
SELECT * FROM inventoru_sync1c_import_pending WHERE ew_id=? AND status='pending';

-- Проверить, куда смотрит writeoff-статус
SELECT data FROM config_global WHERE data LIKE '%sync1c:%';

-- Настройки OData-коннектора
SELECT id, title, protocol, config FROM inventoru_sync1c_connector;
Проблема Причина Решение

Списание не уходит в 1С

ExportPoller не запущен, запись не pending, или инстанс не hs (для odata отправки нет by design)

Проверить scheduler.task.sync1c_export.* в bgerp.properties, статус записи и протокол инстанса

Вкладка «Materials» не блокируется после экспорта

Нет success-записи, либо она уже reversed

SELECT status FROM inventoru_sync1c_export_queue WHERE process_id=?

Форма «Materials» не разблокируется после возврата из Согласовано

Sync1cExportListener не сработал (например, статус меняли напрямую в БД, минуя ProcessChangedEvent)

Менять статус только через штатный /user/process:processStatusUpdate, не прямым UPDATE

OData-импорт молчит по конкретному складу

У склада не заполнен параметр GUID склада 1С — такие склады поллер пропускает намеренно: пустой ответ прочитался бы как «склад пуст». В логе WARN «Import skipped for store … не задан GUID»

Заполнить параметр, названный ключом store.guidParamId коннектора (см. Чем склад ERP называет себя в 1С), либо пользоваться ручным импортом с превью (odataForm)

OData-превью падает с ошибкой "no 'value' array"/HTTP 404

Неверные имена метаданных (odata.balancePath и др.) — они различаются между конфигурациями 1С

Открыть {api_url}/$metadata в браузере, найти реальные имена регистра/полей, поправить конфиг коннектора

Импорт создаёт дубли номенклатуры/складов

item_ext_id_param_id/store_ext_id_param_id не заданы или указывают на несуществующий параметр

Проверить настройки инстанса, что параметр реально существует и заполняется

Штрихкоды не импортируются

Пустой odata.barcodePath (по умолчанию выключено) или GUID номенклатуры ещё не заведён в ERP

Задать odata.barcodePath в конфиге коннектора; сначала прогнать импорт остатков, затем штрихкоды