Sub-package плагина Inventory (не отдельный плагин) — двусторонняя синхронизация остатков материалов монтажников с 1С.
| Направление | Когда | Что происходит |
|---|---|---|
|
1С → ERP |
Для HS и OData — по расписанию ( |
Импорт остатков склада монтажника → 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С | Когда использовать |
|---|---|---|
|
|
Кастомный HTTP-сервис ( |
Есть свой 1С-программист — полностью автоматический обмен в обе стороны |
|
|
Ничего — только штатный отчёт 1С в CSV и ручное проведение |
Нет 1С-программиста (большинство небольших клиентов) — см. [setup-protocol-file] |
|
|
Ничего программного — только публикация базы на веб-сервере со стандартным OData-интерфейсом ( |
Нет 1С-программиста, но есть возможность опубликовать базу — автоматический импорт остатков и штрихкодов без кода на стороне 1С; экспорт списаний в 1С по этому протоколу невозможен (read-only), см. Протокол |
|
|
Ничего программного — готовая внешняя обработка (.epf), один раз собирается BGERP/подрядчиком под типовую конфигурацию и раздаётся клиентам |
Планируется как промежуточный вариант — автоматизация без необходимости публиковать веб-сервис на стороне 1С |
Администрирование → Инвентарь → Синхронизация с 1С → Инстанции. Один инстанс = одно подключение к одной базе 1С:
| Поле | Назначение |
|---|---|
|
|
Название для админки |
|
|
|
|
|
Для |
|
|
HTTP Basic Auth — для |
|
|
id text-параметра номенклатуры, где хранится |
|
|
id text-параметра склада, где хранится идентификатор склада в 1С. Именно он уходит при экспорте списания — см. Каким идентификатором называется склад при экспорте |
|
|
Свободные |
|
Не задан Оба симптома тихие — ни ошибки, ни предупреждения на экране. Заводя коннектор, параметр внешнего кода задают первым; если номенклатура уже импортирована без него, значения нужно проставить разово, иначе следующий импорт её не узнает. |
Кнопка Test connection (instance:testConnection) доступна для протоколов hs и odata: для hs дёргает /stocks и смотрит на HTTP-статус, для odata запрашивает одну строку балансовой таблицы ($top=1 по odata.balancePath) — заодно проверяет и корректность имени регистра, не только доступность/авторизацию.
Идентичность склада описывается параметрами самого склада; коннектор называет, в каких параметрах она лежит:
| Настройка коннектора | Что в параметре |
|---|---|
|
|
Код склада в 1С. Уходит при экспорте списания — см. Каким идентификатором называется склад при экспорте |
|
|
GUID склада 1С: подставляется в |
|
|
Имя склада в 1С — по нему 1С ищет склад при импорте по |
|
|
Локация/объект, если база 1С их различает |
|
|
Путь метода остатков, по умолчанию |
|
|
Путь метода списания, по умолчанию |
Склад участвует в обмене с инстансом, если у него заполнен параметр GUID этого инстанса. Отдельного списка складов нет и заводить его не нужно: заполненный параметр и есть согласие на обмен, а пустой — отказ. Для двух баз 1С заводятся два параметра.
Таблица inventoru_sync1c_engineer_warehouse (админка → Маппинг) осталась, но заводить в ней записи руками больше не требуется: строка создаётся при первом обращении к складу и обновляется из параметров при каждом импорте. На неё ссылаются лог импорта и строки ожидающих поступлений, поэтому удалять строку с непроведёнными поступлениями система не даёт.
|
Склад, у которого нет ни GUID (для |
Тот же 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 — раздел для настройки самой интеграции (инстансы, маппинг, логи); кнопки загрузки файла здесь намеренно нет.
Дальше — три шага:
Выбор склада и файла (importFileForm → import_file_upload.jsp) — инстанс 1С автовыбирается, если он один (при нескольких — выпадающий список); список складов отфильтрован по доступу вызывающего (см. доступ к складам) — обычный пользователь видит только свои склады, полный админ — все.
Превью (importFilePreview, read-only, ничего не пишет в БД) — парсит CSV, резолвит каждую строку по product_id (внешний ID номенклатуры), показывает таблицу: чекбокс принять/отклонить строку, найденное наименование (или «новая позиция: …», если товар с таким product_id в ERP ещё не заведён), редактируемое поле «Quantity».
Подтверждение (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С грузится без настройки:
| Что | Принимаемые имена |
|---|---|
|
Идентификатор |
|
|
Наименование |
|
|
Количество |
|
|
Характеристика (необяз.) |
|
|
Серийный номер (необяз.) |
|
|
Вид номенклатуры (необяз.) |
|
Если в конкретной выгрузке колонка называется иначе — имя задаётся в конфигурации коннектора:
csv.itemIdField=Ном
csv.itemNameField=Что это
csv.qtyField=Сколько
Когда обязательной колонки нет, сообщение об ошибке перечисляет ожидаемые имена и ключ для переопределения — чтобы кладовщик с непривычным файлом видел, чего не хватает, а не просто отказ.
Как читается количество. Дробная часть — и через точку, и через запятую; разряды могут быть разделены пробелом, в том числе неразрывным, как их пишет Excel (1 234,5 → 1234.5). Ячейка, которая числом не является, называет номер своей строки и своё содержимое — файл длинный, оператор нет. Пустая ячейка — это ноль, а не пропуск: файл задаёт итоговый остаток, поэтому пустое количество означает «позиции больше нет», и такая строка проводится, обнуляя остаток в ERP.
|
Остатки хранятся с точностью до трёх знаков после запятой ( |
Списание уходит в 1С с идентификатором склада, и берётся он так:
у коннектора задан store_ext_id_param_id и у склада этот параметр заполнен — уходит его значение;
параметр задан, а у склада пусто — запись экспорта не отправляется, а помечается ошибкой «У склада не заполнен параметр внешнего кода». Тихо отправить внутренний идентификатор нельзя: принимающая сторона не может его разобрать и обычно подставляет какой-нибудь свой склад — тогда списание запишется не туда и вернётся как успешное;
параметр у коннектора не задан вовсе — уходит то, что лежит в записи очереди: настоящий 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-сервиса (поле |
Один тип на все создаваемые позиции — это стартовая настройка, а не конечная: тип несёт ещё и вид учёта (расходник или основное средство), а он у инструмента и у кабеля разный. Разумная раскладка выводится из колонки выгрузки «Вид номенклатуры» — в типовой выгрузке 1С это Материалы, Товары, Инвентарь и хозяйственные принадлежности, Спецодежда, Оборудование к установке; первые две и последняя — расходники, инвентарь и спецодежда — основные средства, которые не резервируются под процесс, а передаются между складами. Импорт эту колонку не читает (в ERP нет соответствия видам 1С), поэтому типы заводят руками и раскладывают позиции по ним разово.
Строка «Итого». Выгрузка 1С обычно заканчивается итоговой строкой: наименование и сумма без кода. По умолчанию она попадает в предпросмотр как обычная строка — снять с неё галку дело одного клика, и только оператор знает, итог перед ним или позиция. Если файлы этой базы всегда приходят с итогом, его можно отбрасывать сразу:
csv.skipLastLine=1
|
Позиции с поштучным учётом ( |
Пример файла — 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. Как и у протоколов |
Кладовщик формирует такой файл штатным отчётом 1С (или выгрузкой из консоли/обработки) — сам ERP не диктует, как именно 1С сформирует данные, только формат файла. Число — с точкой в качестве разделителя (при заковыченном поле "15,000" парсер примет и запятую); строго проверяется на клиенте перед отправкой — нечисловое значение блокирует подтверждение целиком, а не тихо обрезается. Файл кодируется в UTF-8 (с BOM или без — оба варианта читаются).
ImportService.importFromRows() — то же самое ядро (upsert item/store, запись в inventoru_sync1c_import_pending), что и у остальных протоколов, только источник строк другой — не HTTP, а разобранный CsvUtil.parse() файл. Дальнейшее подтверждение (см. Импорт остатков — с подтверждением расхождений) не отличается вообще.
Загрузка 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: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-протокол read-only — канала передачи списания в 1С нет. Поэтому при входе процесса в writeoff-статус (ExportService.enqueue()) для складов OData-инстансов:
запись очереди экспорта создаётся сразу в статусе success (атомарно — конкурентный повторный вход не продублирует), без единого HTTP-вызова;
тут же создаются локальные writeoff-движения в ERP (баланс и резерв уменьшаются) — 1С и так мастер выданных документов, ERP нужно лишь собственное списание; следующий импорт остатков его подтвердит;
success-запись сохраняет обе привязанные к ней механики: блокировку вкладки «Materials» (hasSuccessExport()) и сторно при возврате процесса из согласованного статуса (reverseWriteoffMovements(), см. Сценарий «Возврат из Согласовано» — реверс уже отправленного списания) — они работают для OData так же, как для HS/File;
в админке Повторить для таких записей невозможен (разрешён только из pending/failed — success вернуть в pending нельзя ни для какого протокола: списание уже проведено, а для OData его и физически некуда переотправить), Delete запрещён для success/reversed — удаление стёрло бы единственный маркер блокировки вкладки «Materials» при живых writeoff-движениях в журнале.
importFromRows() не создаёт pending-запись на каждую строку ответа — только на реальные расхождения, чтобы не приучать владельцев складов жать «Apply all» вслепую:
1С == ERP — количество из 1С совпадает с текущим inventoru_balance.quantity: pending не создаётся; если по этой позиции висело старое неподтверждённое предложение — оно удаляется как устаревшее (deleteIfPending).
1С ≠ ERP — обычный upsert в pending, дальше стандартное подтверждение (см. Импорт остатков — с подтверждением расхождений).
Позиция пропала из ответа (обнуление, см. Обнуление остатков — позиция пропала из ответа) — pending с нулём создаётся только если текущий баланс ERP ненулевой; при уже нулевом балансе предлагать нечего — максимум чистится устаревший pending.
Для OData дополнительно: склады без GUID склада 1С пропускаются целиком (см. Чем склад ERP называет себя в 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": "ст-ца Брюховецкая - ОМИПЛАТ" }
Запрос:
| Поле | Тип | Значение |
|---|---|---|
|
|
строка |
Имя склада в 1С — берётся из параметра склада, названного ключом |
|
|
строка |
Локация/объект, если база их различает; иначе пустая строка |
Ответ — массив data; поля на элемент:
| Поле | Тип | Значение |
|---|---|---|
|
|
строка |
Код позиции в 1С — по нему ERP находит номенклатуру через параметр внешнего кода. Без него строка не сопоставляется |
|
|
строка |
Наименование; им создаётся позиция, которой в ERP ещё нет |
|
|
число |
Итоговый остаток на складе, не приход |
|
|
строка |
Необязательно. Вид номенклатуры («Материалы», «Товары»…) — раскладывает создаваемые позиции по типам, см. Имена колонок |
|
|
строка |
Необязательно. Характеристика позиции |
|
|
строка |
Необязательно. Серийный номер |
|
|
строка |
Идентификатор склада в 1С; читается только при создании склада из 1С |
|
|
— |
Принимаются и игнорируются, см. ниже |
Что из этого ERP на самом деле использует. Импорт читает product_id (сопоставление позиции по параметру внешнего кода), product_name (наименование создаваемой позиции), stocks (итоговый остаток), warehouse_id (при создании склада из 1С) и, из файлового источника, характеристику, серийный номер и вид номенклатуры. Остальные поля контракта ERP принимает и игнорирует:
| Поле | Что с ним |
|---|---|
|
|
Не читается нигде. Оставлено в контракте как исторический второй регистр остатков |
|
|
Не читается: какие склады участвуют в обмене, ERP определяет параметрами склада, а не признаком из ответа |
|
|
При импорте не читаются. В экспорте списания поле |
|
|
Не читаются: остатки в ERP не разделяются по организациям |
Поля не удалены из контракта намеренно: 1С-сервисы у разных клиентов уже их отдают, и требовать переписывания ради чистоты ответа смысла нет.
|
Контракт по обнулившимся позициям (решено): если остаток по позиции стал равен нулю, она полностью пропадает из массива |
|
Имена полей чувствительны, лишние игнорируются. Разбор идёт по точным именам из таблиц; поле, названное иначе, молча становится пустым ( |
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 }
]
}
Поля запроса:
| Поле | Тип | Значение |
|---|---|---|
|
|
число |
Процесс ERP — внешняя ссылка для документа 1С и ключ идемпотентности |
|
|
строка |
Дата отправки (текущая дата ERP), не дата резервирования |
|
|
строка |
Идентификатор склада — значение параметра внешнего кода склада, см. Каким идентификатором называется склад при экспорте |
|
|
строка |
Код позиции в 1С — значение параметра внешнего кода номенклатуры |
|
|
строка |
Необязателен и физически отсутствует в JSON, когда пуст ( |
|
|
число |
Количество; дробная часть через точку |
Ответ — тот же общий формат, "data" при успехе игнорируется целиком: ERP смотрит только на error/errorText и на HTTP-статус.
|
Ответ без |
Требуется идемпотентность на стороне 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-специфичном коде.
Процесс переходит в статус, указанный в sync1c:processType.<ID>.statusWriteoff → Sync1cExportListener ставит запись(и) в очередь inventoru_sync1c_export_queue — по одной на каждый склад, с которого процесс резервировал (upsert по process_id + instance_id + store_id — повторный вход в статус сбрасывает существующие записи обратно в pending). Если несколько складов процесса замаплены на один и тот же 1С-инстанс, каждый получает свою запись и свой отдельный вызов /writeoff, а не смешивается в один. Это общий шаг для всех протоколов; для OData запись сразу становится success и списание проводится локально — см. Экспорт при OData — локальное списание без отправки.
Протокол hs — ExportPoller (раз в минуту) забирает pending-записи инстансов с protocol='hs', отправляет /writeoff со всеми активными резервами процесса с этого склада, при успехе создаёт writeoff-движения (уменьшают quantity и reserved) и помечает запись success. При ошибке — failed (до sync1c:export.maxAttempts попыток, видно в админке с текстом ошибки, кнопка Retry; на исчерпании попыток — алярм админу). Перед отправкой поллер перепроверяет, что запись всё ещё pending — если её успели отменить конкурентно (см. ниже), отправка пропускается.
Протокол file — ExportPoller эти записи не трогает вообще (см. Планировщик). В 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.
| Статус | Значение |
|---|---|
|
|
Ждёт отправки (или переотправки после сброса) — не бывает у OData-инстансов |
|
|
Списание проведено: для |
|
|
Ошибка при отправке (текст — |
|
|
Процесс вернули «на доработку» до фактической отправки — см. Сценарий «На доработку» — отмена ещё не отправленного экспорта |
|
|
Списание было |
Правила ручных действий в админке: 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():
Для каждого ещё не реверснутого writeoff-движения процесса с ext_ref='sync1c' создаётся receipt-движение на ту же номенклатуру/склад/количество (НЕ return — тот дополнительно уменьшает reserved, что задело бы чужие резервы на этом складе/номенклатуре, никак не связанные с данным процессом).
Все success-записи очереди экспорта процесса переводятся в reversed.
Вкладка «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>>
| Таблица | Назначение |
|---|---|
|
|
Подключения к базам 1С (url, credentials, protocol, ext_id param ids, config с |
|
|
Техническая связка склад ERP ↔ склад 1С; ведётся кодом из параметров склада, руками не заполняется (см. Чем склад ERP называет себя в 1С) |
|
|
Расхождения остатков от 1С, ждущие подтверждения (UNIQUE |
|
|
История запусков импорта |
|
|
Очередь экспорта списаний — одна запись на каждую пару (процесс, склад), даже если несколько складов процесса замаплены на один и тот же 1С-инстанс (UNIQUE |
|
|
История попыток отправки списания — одна запись на каждый прогон |
|
|
Штрихкоды номенклатуры (наполняется OData-импортом, лежит в основном плагине) |
ref_movement_idКак и весь inventoru_movement — записи только добавляются. Связь «это движение — следствие того движения» — через ref_movement_id:
unreserve.ref_movement_id = reserve.id — ручная отмена резерва
writeoff.ref_movement_id = reserve.id — списание при экспорте (createWriteoffMovements)
receipt.ref_movement_id = writeoff.id — реверс списания (reverseWriteoffMovements, см. Сценарий «Возврат из Согласовано» — реверс уже отправленного списания)
«Активная» 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С |
|
Проверить |
|
Вкладка «Materials» не блокируется после экспорта |
Нет |
|
|
Форма «Materials» не разблокируется после возврата из Согласовано |
|
Менять статус только через штатный |
|
OData-импорт молчит по конкретному складу |
У склада не заполнен параметр GUID склада 1С — такие склады поллер пропускает намеренно: пустой ответ прочитался бы как «склад пуст». В логе WARN «Import skipped for store … не задан GUID» |
Заполнить параметр, названный ключом |
|
OData-превью падает с ошибкой "no 'value' array"/HTTP 404 |
Неверные имена метаданных ( |
Открыть |
|
Импорт создаёт дубли номенклатуры/складов |
|
Проверить настройки инстанса, что параметр реально существует и заполняется |
|
Штрихкоды не импортируются |
Пустой |
Задать |