О модуле

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

hs.writeoff.operation

Значение поля operation в теле списания. Не задано — поля нет. Значение realization отклоняется: реализация требует ИНН покупателя и идентификатор договора, которых ERP не передаёт

hs.writeoff.serialField

Имя поля серийного номера в строке списания, по умолчанию serial

hs.writeoff.contractLinkType

Тип привязки процесса, из которой берётся поле contract (название привязанного объекта), — выражение MySQL LIKE, например contract:% для договора любого биллинга. Не задано — поля нет. Несколько разных договоров у процесса — отказ (на какой списывать, ERP не выбирает); один и тот же договор, привязанный дважды, — не выбор

hs.writeoff.organization.paramId, hs.writeoff.organization.value.<id>, hs.writeoff.organization

Поле organization_name: организация 1С по значению спискового параметра процесса. Параметр у процесса не заполнен — берётся hs.writeoff.organization; заполнен, но значение не сопоставлено — отказ (списать от имени «организации по умолчанию» чужую отгрузку нельзя); несколько значений, указывающих на разные организации, — отказ. Без paramId — одна организация на базу (hs.writeoff.organization). Не задано ни одно — поля нет

hs.writeoff.location

1 — поле location из параметра склада store.locationParamId

Без ключей hs.writeoff.* тело списания не меняется. Включённое поле, которое у процесса или склада не заполнено, не отправляется в 1С пустым: запись очереди получает ошибку с указанием, чего не хватает, — сервис всё равно отказал бы, а «HTTP 400» не говорит, какие данные процесса пусты.

Такой отказ — это состояние данных, а не сбой связи, но попытка считается общим счётчиком: после sync1c:export.maxAttempts запись уходит в failed и поллер её больше не берёт. Заполнили договор или сопоставили организацию позже — списание само не уйдёт: нажать Retry в Export Queue (счётчик попыток сбрасывается) либо перевести процесс в статус списания заново (повторный вход сбрасывает записи в pending).

Сервис 1С с методом /operation (пример)
# api_url инстанса: http://1c-server/base/hs
hs.stocksPath=/erp/stocks/
hs.writeoffPath=/erp/operation/
hs.writeoff.operation=writeoff
hs.writeoff.serialField=serial_number
hs.writeoff.contractLinkType=contract:%
hs.writeoff.organization.paramId=68
hs.writeoff.organization.value.1=ОМИПЛАТ ООО
hs.writeoff.location=1

В ответе остатков серийный номер читается и как serial, и как serial_number.

Ответ на списание: отказом считается только JSON с непустым error. Статус 2xx с пустым телом, с текстом («OK») или со страницей HTML — списание принято; тело пишется в лог ошибкой. Иначе непонятный ответ уводил бы запись очереди в повтор, и 1С списала бы тот же товар второй раз.

Склад участвует в обмене с инстансом, если у него заполнен параметр 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. Выбор склада и файла (importFileForm → import_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, Вид номенклатуры, Вид — раскладка по типам, см. ниже

Единица измерения (необяз.)

unit, Ед. изм., Единица, Ед, Единица измерения — см. Единицы измерения

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

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

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

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

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

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

Списание уходит в 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). Для склада, который 1С не ведёт (протокол file), серийный номер в файле считается количеством: заменить меняет только часть остатка без серийных номеров, единицы ставит импорт оборудования; в предпросмотре такие строки помечены. Добавить приходует поштучную позицию только построчно — строка на единицу, с серийным номером и количеством 1; строка без серийника отказывает весь файл, ничего не записав.

Заменить или добавить

На форме загрузки выбирается режим, а перед отправкой — подтверждение с объяснением, что сейчас произойдёт:

Режим Что делает

Заменить остаток (по умолчанию)

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

Добавить к остатку

Количество в файле — то, что пришло. Строки приходуются сразу (движение «приход»), поверх текущего остатка, без «Ожидающих». Позиции, которых нет в файле, не затрагиваются. Только для коннектора с протоколом file: где остаток ведёт 1С (hs, odata), следующий импорт вернул бы остаток к 1С-ному и добавка пропала бы — поэтому там режим отказывает. Своё право — /admin/plugin/inventoru/sync1c/instance:importFileAdd: режим пишет остаток сразу, мимо очереди подтверждения, и право на подтверждение загрузки (importFileConfirm) его не даёт; без права выбора «Добавить» на форме нет. Все строки проверяются до записи, отказ ничего не оставляет; строки без количества пропускаются и называются после прихода

Строки уходят на сервер в теле запроса, а не в адресе: длинный файл в адресе упирался в предел размера заголовка. Вид номенклатуры, характеристика и серийный номер строки доходят до сервера — раньше подтверждение передавало только код, наименование и количество, и вид, определяющий тип создаваемой позиции, терялся. То же — в ручном импорте по OData.

Пример файла — 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 и последующее подтверждение в «Ожидающих поступлениях» — это ДВЕ отдельные точки, обе проверяют конкретный склад, а не только «есть ли право на действие вообще», — но разными мерками (роли — см. доступ к складам):

  • AdminSyncAction.importFileForm/importFilePreview/importFileConfirm/importFileAdd (и OData-аналоги odataForm/odataPreview) — только склады, которыми пользователь управляет: его группа — «группа редактирования» склада. Загрузка в режиме «заменить» переписывает весь остаток склада, это не дело монтажника, даже если склад его личный. Список складов фильтруется и storeId из запроса проверяется на сервере (не только скрытием в UI).

  • UserImportPendingAction (страница «Pending incoming») — видит и может провести/отклонить запись любой, кто со складом работает (владелец, выданный доступ, группа редактирования): предложения пришли из 1С, которая главная по остаткам, и проведение — это подтверждение полученного, а не правка склада.

  • Админские 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:odataForm → odataPreview): форма с 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» (hasWriteoffInErp()) и сторно при возврате процесса из согласованного статуса (reverseWriteoffMovements(), см. Сценарий «Возврат из Согласовано» — реверс уже отправленного списания) — они работают для OData так же, как для HS/File;

  • в админке Повторить для таких записей невозможен (разрешён только из pending/failed — success вернуть в 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

строка

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

unit

строка

Необязательно. Единица, в которой дано stocks — единица хранения номенклатуры в 1С («шт», «м», «км»…). Позиция запоминает её при первом появлении; строка в другой единице не складывается с позицией, см. Единицы измерения

serial

строка

Серийный номер единицы. Для позиции с поштучным учётом (в 1С — серии номенклатуры с учётом остатков по складам) 1С присылает строку на каждую единицу со stocks=1: ERP сверяет, какие именно единицы стоят на складе, см. Поштучные позиции: сверка по серийным номерам. Для прочих позиций не нужен

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

число

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

items[].serial

строка

Серийный номер единицы — у позиции с поштучным учётом строка на каждую единицу, quantity=1; это ровно та единица, которую назвал резерв монтажника. 1С с сериями номенклатуры требует серию в документе списания — сервис должен ставить её в документ. У прочих позиций поле отсутствует в JSON (@JsonInclude(NON_EMPTY))

Сервис 1С, который поле serial молча игнорирует, спишет не ту серию (или откажет, если серия обязательна). Пока сервис его не разбирает, процессы с поштучными позициями на списание в 1С не отправлять. Файловый протокол несёт серийник последней колонкой serial CSV списания.

Ответ — тот же общий формат, "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, баланс не трогается, движение не пишется.

  • Отклонить показанные / Отклонить все (rejectAll, в админке importRejectAll) — отклоняет тот же отбор, с каким работает «Провести всё»: склад и позицию либо всё, что сейчас на экране. Спрашивает перед выполнением — отклонённое возвращается со следующим импортом молча, а проведённое видно в остатке, поэтому ошибиться в эту сторону дороже. Нужно, когда списание без 1С возвращает свои позиции каждый цикл: построчный разбор десятков строк при каждом импорте — отдельная работа.

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

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

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

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

Поштучные позиции: сверка по серийным номерам

Позицию с поштучным учётом 1С (серии номенклатуры с учётом остатков по складам) присылает строкой на каждую единицу. 1С тут главная не только по количеству, но и по тому, какая единица на каком складе, — поэтому импорт сверяет серийные номера склада в 1С с единицами, числящимися на складе в ERP, и предлагает к проведению не количество, а единицы:

Строка Что сделает проведение

+ серийник

Единица есть в 1С на этом складе, в ERP — нет. Если она числится на другом складе, который ведёт 1С, — перемещение оттуда; если не числится нигде — встаёт на склад: к остатку, который ждал серийных номеров, или приходом; неизвестный серийник заводится устройством

− серийник

Единица числится на складе в ERP, а в 1С её там нет — уходит со склада корректировкой на одну единицу. Позиция, целиком пропавшая из ответа, даёт такую строку на каждую свою единицу

Остаток без серийных номеров

1С держит часть позиции без серий (или источник вообще не присылает серий — тогда сверяется только количество): эта часть остатка становится такой, как в 1С

Только на складах, которые ведёт 1С. По единицам сверяется склад, сопоставленный с включённым коннектором hs: там 1С знает, какая серия где лежит. На складе коннектора file или odata единицы расставляет ERP (импорт оборудования, приходы), и от 1С берётся только количество: меняется остаток без серийных номеров, единицы не трогаются. Если по такой выгрузке единиц меньше, чем стоит на складе, — это конфликт, а не догадка, какие из них ушли; позиция, пропавшая из выгрузки без серий, не снимает со склада все свои единицы.

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

Что проведение не делает само. Такие строки выделены красным, у них нет кнопки «Провести»:

  • единица зарезервирована в процессе — 1С её со склада убрала, а монтажник на неё рассчитывает;

  • единица списана в процессе, а 1С показывает её на складе. Если списание случилось уже после снимка 1С, строка просто закроется при проведении. Иначе — кнопка Принять возврат: осознанное решение, что 1С действительно вернула единицу;

  • единица числится на складе, который 1С не ведёт — переместить её оттуда должен человек;

  • 1С показывает меньше единиц, чем числится в ERP, но без серийных номеров — каких именно не хватает, неизвестно; лишние списываются по серийным номерам вручную.

Отклонённая строка при следующем импорте появится снова, пока 1С показывает то же самое: 1С тут главная, и расхождение не исчезает оттого, что его отклонили.

Экран. Строки сгруппированы по складу и позиции: одна строка группы — «37 −2», число конфликтов и кнопка *Провести все*; серийники раскрываются кнопкой *Единицы* (конфликтные видны сразу). Фильтры — по складу и по виду строк, включая *Только конфликты*; *Провести по складу* проводит всё выбранного склада. Массовое проведение идёт в порядке «», «−», остаток без серий, количества — единица, вставшая на склад, успевает привязаться к ждущему остатку прежде, чем его поправит корректировка. Отказ одной строки не останавливает остальные: после проведения показывается, что не проведено и почему. Кнопки массового проведения учитывают фильтр: при «Только конфликты» проводится только показанное.

Единицы измерения

Позиция ERP — это номенклатура 1С (один код — одна позиция), и у неё одна единица: та, в которой 1С ведёт остатки этой номенклатуры. Единица приходит полем unit в /stocks или колонкой «Ед. изм.» в файле и запоминается позицией при первом появлении; её можно задать и на карточке позиции. Написания сравниваются нормализованно: «шт», «шт.», «штука», «pcs» — одно и то же, как и «м»/«метр», «км»/«километр».

Раньше единицы не было вовсе, и один и тот же кабель, пришедший как «1 шт», «300 м» и «0.3 км», молча складывался в 302.3 неизвестно чего. Теперь импорт (HTTP, файл, OData) не берёт строки позиции, если:

  • в одной выгрузке у одного кода разные единицы;

  • единица строки не та, в которой ведётся позиция;

  • позиция ведётся в штуках (или комплектах), а количество дробное.

Остальная выгрузка загружается как обычно, а что не загружено и почему — в журнале импорта. Такая позиция не считается пропавшей из выгрузки и к обнулению не предлагается.

Позиция в штуках не принимает дробное количество нигде — ни в резерве на вкладке «Materials», ни в приходе, корректировке, перемещении, режиме «добавить» загрузки CSV: это ошибка единицы, а не количество. Единица показывается рядом с остатком и количеством во вкладке «Materials» и в «Ожидающих поступлениях».

Если в 1С кабель заведён несколькими номенклатурами (бухта 1 км, бухта 300 м, метры), в ERP это несколько позиций — как в 1С; пересчёт бухт в метры ERP не делает.

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

Раз 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>.statusWriteoff → Sync1cExportListener ставит запись(и) в очередь inventoru_sync1c_export_queue — по одной на каждый склад, с которого процесс резервировал (upsert по process_id + instance_id + store_id — повторный вход в статус сбрасывает существующие записи обратно в pending). Если несколько складов процесса замаплены на один и тот же 1С-инстанс, каждый получает свою запись и свой отдельный вызов /writeoff, а не смешивается в один. Это общий шаг для всех протоколов; для OData запись сразу становится success и списание проводится локально — см. Экспорт при OData — локальное списание без отправки.

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

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

Изменение поведения, 29.09.2026. Раньше файловый протокол списывал в ERP в момент постановки в очередь — до того, как кто-либо перенёс документ в 1С, — и описанный здесь путь «скачал CSV → провёл в 1С → Подтвердить» был недостижим: подтверждать было нечего. Теперь файловый протокол ждёт подтверждения так же, как push: выгрузка файла ничего не говорит о судьбе документа (его могут не открыть, провести частично, провести в другой склад), и до подтверждения остаток трогать нельзя. OData оставлен как был — этот протокол только читает 1С и сообщить ей ничего не может, поэтому ждать там нечего и подтверждать некому.

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

Что отправку останавливает

Списание уходит в 1С только целиком. Если хотя бы у одной позиции не заполнен параметр внешнего кода номенклатуры, отправки не происходит вовсе, а запись получает ошибку с перечнем таких позиций по названиям (первые пять, дальше «и ещё N»).

До 29.09.2026 отправлялось то, что удалось назвать: при 2 сопоставленных позициях из 84 в 1С уходил документ на 2 строки, а ERP по успешному ответу списывала все резервы процесса по этому складу — 84. Расхождение возникало молча, единственным следом была строка в логе сервера. Документ из 2 строк вместо 84 к тому же неверен и на стороне 1С.

Так же останавливает отправку незаполненный внешний код склада — в тексте ошибки склад называется по имени, а не по id параметра.

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

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

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

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

pending

На экране — «В очереди». Ждёт отправки: и первой, и повторной после неудачи, пока не исчерпан лимит попыток (attempts растёт, текст последней ошибки — last_error) — не бывает у OData-инстансов

success

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

failed

Попытки исчерпаны (export.maxAttempts, текст — last_error): поллер такую запись больше не берёт, нужен Повторить на экране очереди; админу уходит алярм

cancelled

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

reversed

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

local

Списано без 1С, сознательно. Кто-то решил, что это списание в 1С проведено не будет (1С надолго недоступна, обмен выключен), и закрыл запись руками: ERP-движения созданы, резерв снят, вкладка «Materials» заблокирована как у success. Отличается от него намеренно: 1С эти позиции по-прежнему держит на складе, поэтому следующий импорт остатков предложит их обратно, и эта запись — единственное объяснение почему. Кто и когда — в колонке «Кем закрыто»

unposted

1С списание приняла, а в ERP провести его не удалось (например, серийник из резерва не найден среди устройств). Всё, что успело записаться в ERP, откатывается; запись больше не отправляется в 1С повторно — иначе документ списания задвоился бы. Текст причины — last_error, админу уходит алярм. Вкладка «Materials» остаётся заблокированной, как у success: списание в 1С есть

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

Подтвердить: проведено в 1С (exportQueueConfirm) и Списать без 1С (exportQueueWriteoffLocal) — из pending и из failed, на любом протоколе. Оба создают одни и те же writeoff-движения и снимают резерв, различаются только статусом записи (success против local) и тем, что после них считать правдой в 1С. Раньше такое подтверждение было только у протокола file: у hs резерв завершённого процесса ждал ответа 1С и при выключенном обмене не дожидался никогда — ровно тот случай, ради которого это и сделано. Запись атомарно захватывается до создания движений, так что двойной клик и гонка с Confirm all не дают двойного списания, а закрытая запись отвечает «уже обработана». Кто нажал и когда — confirmed_by/confirmed_at, колонка «Кем закрыто»; у записей, которые закрыл поллер сам, там «автоматически». Если обмен инстанса выключен, экран говорит об этом над таблицей: записи такого инстанса поллер не заберёт никогда.

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

Процесс вернули на доработку до того, как 1С подтвердила списание. Выход из статуса списания сам отменяет неотправленное: Sync1cExportListener зовёт ExportQueueDAO.cancelPendingByProcessId(), и записи pending и failed становятся cancelled — материалы процесса, который снова в работе, не спишутся. То же делает отмена резерва монтажником (unreserve). Подтверждённые записи (success, unposted) не отменяются, а реверсируются — см. Сценарий «Возврат из Согласовано» — реверс уже отправленного списания. При повторном переходе в СОГЛАСОВАНО запись пересоздаётся штатным upsert, экспорт уходит заново.

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

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

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

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

  2. Все success- и unposted-записи очереди экспорта процесса переводятся в 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() — склады,
│   │                                на которых действует узел вызванного метода (по умолчанию —
│   │                                группы редактирования, опция stores узла); маркер "полный админ" — владение
│   │                                /admin/plugin/inventoru/sync1c/instance:null
│   └── UserImportPendingAction.java — /user/plugin/inventoru/import_pending;
│                                      getAccessibleStoreIds() — по узлу метода, умолчание work (владелец ∪ 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/hasWriteoffInErp/
│                                    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/UNPOSTED), 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

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

inventoru_sync1c_export_log

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

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 в конфиге коннектора; сначала прогнать импорт остатков, затем штрихкоды