Задача на двадцать минут
Кондитерская фабрика. Не магазин, не розница — производство, которое само заказывает коды маркировки на свою продукцию, печатает и клеит на упаковку. Коды приходят выгрузкой, дальше их надо завести в базу и разложить по позициям.
Клиент сформулировал задачу так: «коды приходят, но 1С их не принимает». Всё.
Открываю файл. Excel, один столбец, 2 470 строк. Никаких пустых ячеек, никаких объединённых заголовков, никакой обычной прайсовой дичи. Строки начинаются одинаково, длина плюс-минус одна и та же, символы читаемые. Файл, честно говоря, выглядел приличнее девяноста процентов того, что мне присылают.
Ну, думаю, двадцать минут.
Первое, на что я подумал, — кодировка. Самая скучная и самая частая причина. Полчаса на это убил и только потом сообразил, что подозревать тут нечего: в коде маркировки кириллицы нет вообще, там латиница, цифры и знаки препинания. Кодировка ни при чём физически.
Дальше — пробелы по краям. Классика: Excel любит подсунуть неразрывный пробел или табуляцию, и строка перестаёт совпадать. Обернул всё в СокрЛП, перезапустил. Ничего не изменилось.
Тут я начал раздражаться. Файл выглядит правильно, программа говорит «нет», и никакой зацепки.
От безысходности взял один код из файла и один код, считанный сканером с настоящей упаковки той же продукции. Положил рядом в окно сообщений. Смотрю — одинаковые. Буква в букву.
Потом сравнил длину.
44 и 42.
Я минуты три сидел и смотрел на эти два числа. Строки одинаковые, а длина разная. Так не бывает.
Два символа, которых не видно
Первое, что я сделал дальше, — вывел обе строки посимвольно с кодами. Штука на пять строчек, но именно она всё и решила:
Процедура ПоказатьКодыСимволов(Знач Строка) Экспорт
Результат = "";
Для Позиция = 1 По СтрДлина(Строка) Цикл
Символ = Сред(Строка, Позиция, 1);
Код = КодСимвола(Символ);
Если Код < 32 Тогда
// Управляющий символ — печатаем в угловых скобках
Результат = Результат + "<" + Формат(Код, "ЧГ=0") + ">";
Иначе
Результат = Результат + Символ;
КонецЕсли;
КонецЦикла;
Сообщить(Результат);
КонецПроцедуры
На коде со сканера в двух местах вылезло <29>. На коде из Excel — ни одного.
Рис. 1 — посимвольный дамп: со сканера и после Excel
Символ с кодом 29 — это GS, Group Separator. Управляющий символ без графического начертания. Он не пробел: пробел хотя бы занимает место, его видно по ширине. GS не занимает ничего. Его не видно на экране, он не переживает вставку в ячейку Excel и чаще всего не переживает копирование через буфер обмена. Почему именно Excel — разберу ниже, причина там сильнее, чем «программа кривая».
То есть я полдня искал то, чего в принципе нельзя увидеть глазами. Прекрасно.
И тут мне стало любопытно: а типовая-то про этот символ знает? Полез в конфигурацию — скорее из вредности, чем с надеждой. И вот там началось интересное.
Что про это думает сама 1С
Дальше всё — из выгрузки «Бухгалтерии предприятия» 3.0, релиз 3.0.111.25. Про границы этой проверки честно скажу в конце, но имена модулей и функций вы найдёте и у себя.
Первое, что попалось, — в модуле РазборКодаМаркировкиИССлужебныйКлиентСервер лежит функция из одной строки:
Функция РазделительGS() Экспорт
Возврат Символ(29);
КонецФункции
Экспортная функция ради одного символа. Такое пишут не от красоты, а когда константа нужна в десятке мест и её надоело размазывать по коду.
А вот это меня совсем добило. Модуль ИнтеграцияИСМПСлужебный, формирование текста ошибки:
ВызватьИсключение СтрШаблон(
НСтр("ru ='Не удалось разобрать код маркировки: %1
|%2'"),
СтрЗаменить(КодМаркировки, Символ(29), "<GS>"),
ПримечаниеКРезультатуРазбора.ТекстОшибки);
Они специально подменяют невидимый символ на видимую метку, прежде чем показать строку человеку.
То есть разработчики типовой прошли ровно через то же, через что я прошёл сегодня утром, и сделали из этого вывод. А я до того же самого допёр методом трёхминутного разглядывания двух чисел.
Почему без разделителя разбор невозможен в принципе
Код маркировки — не одна строка, а последовательность полей формата GS1. Каждое поле начинается с идентификатора применения (две-четыре цифры), дальше значение. У части полей длина фиксированная, у части — переменная.
Таблица этих идентификаторов лежит прямо в коде, в модуле МенеджерОборудованияМаркировкаКлиентСервер. Выдержка по тем, что реально встречаются в кодах маркировки:
ДобавитьКодGS1(Коды, "01" , "GTIN" , 14);
ДобавитьКодGS1(Коды, "10" , "BATCH_LOT" , , 20, , ТипGS1Строка());
ДобавитьКодGS1(Коды, "17" , "EXPIRE" , 6);
ДобавитьКодGS1(Коды, "21" , "SERIAL" , , 20, , ТипGS1Строка());
ДобавитьКодGS1(Коды, "8005", "PRICE_PER_UNIT" , 6, , , , Истина);
ДобавитьКодGS1(Коды, "91" , "INTERNAL1" , , 90, , ТипGS1Строка());
ДобавитьКодGS1(Коды, "92" , "INTERNAL2" , , 90, , ТипGS1Строка());
Третий параметр — фиксированная длина, четвёртый — максимальная переменная. Закономерность видна сразу: 01 (GTIN) всегда ровно 14 знаков, 17 (срок годности) всегда 6, 8005 (в типовой названо PRICE_PER_UNIT, у табака в этом поле едет МРЦ) всегда 6. А 21 (серийный номер) — до 20, 91 и 92 — до 90.
И вот строчка, ради которой стоило туда лезть:
ОписаниеКода.Вставить("ЕстьРазделитель",
?(Параметры.ПеременнаяДлина > 0, Истина, Параметры.ЕстьРазделитель));
Переменная длина — разделитель обязателен. Фиксированная — не нужен. Всё.
Разберём, что происходит без него. Пусть идёт 21 (серийник, до 20 знаков), сразу за ним 91 (до 90). После идентификатора строка выглядит так:
21AbC1dEf991EE07...
Где кончается серийный номер? Может, AbC1dEf9, а дальше идентификатор 91 со значением EE07. А может, серийник — это AbC1dEf991EE07, потому что серийники бывают какие угодно, в том числе с цифрами 9 и 1 посередине. А может, AbC1dEf991, и тогда следующий идентификатор начинается с EE, что тоже формально не запрещено.
Однозначного ответа нет. Не «трудно найти», а нет — информация о границе поля утеряна физически, вместе с символом. С разделителем строка выглядела бы так:
21AbC1dEf9<GS>91EE07...
и вопросов бы не возникло вовсе.
Рис. 2 — структура кода: где разделитель обязателен
Про FNC1, чтобы не путать
Тут надо развести две вещи, которые все путают. Я сам путал часа полтора, пока не разобрался.
GS (Символ(29)) живёт в строке. Это то, что вы получаете после декодирования: обычный управляющий символ внутри последовательности. Вся эта статья — про него.
FNC1 живёт в символике. Это служебный элемент самого кода DataMatrix, который помечает символ как GS1-совместимый. В декодированной строке его как такового нет — он про то, как код закодирован в картинку, а не про то, что из картинки вышло.
Вылезает это при печати. Если сгенерировать этикетку обычным DataMatrix вместо GS1 DataMatrix, картинка получится, глазами не отличишь, сканер её прочитает — но не как GS1-код. В компонентах печати это разные типы, и в типовой не просто так заведено отдельное перечисление ТипыШтрихкодов. На форуме Инфостарта эту разницу разбирали не раз, там же всплывает и второй нюанс: в ряде компонент FNC1 задаётся во входной строке отдельным служебным символом, а не тем же 29-м.
Короче: потеря GS ломает разбор уже считанного кода, неправильный тип символики ломает печатаемый. Симптом со стороны пользователя один и тот же — «код не принимается», — а лечится в разных местах. Дальше речь только про первое.
А теперь самое красивое
Я полез смотреть, что типовая делает, когда разделителей всё-таки нет. Ожидал увидеть заглушку или исключение.
Увидел функцию с названием ВариантыРазбораШтрихкодаGS1БезРазделителей.
Прочитайте название ещё раз. Не «РазобратьШтрихкодБезРазделителей». Варианты. Множественное число зашито в имя, и это не случайность — функция возвращает массив.
Внутри — вот что (обратите внимание на переменную Стек, к ней ещё вернёмся):
Стек = Новый Массив;
Стек.Добавить(ЭлементСтека);
Пока Стек.Количество() > 0 Цикл
ЭлементСтека = Стек[0];
Стек.Удалить(0);
...
ИначеЕсли ЗначениеЗаполнено(РезультатРазбора.ЗначениеПеременное)
И Не РезультатРазбора.ЗначениеПеременноеОбработано Тогда
МаксимальнаяДлинаЗначенияПеременнойДлины = Мин(
РезультатРазбора.ОписаниеКода.ПеременнаяДлина,
СтрДлина(РезультатРазбора.ЗначениеПеременное));
Для ВариантДлины = 1 По МаксимальнаяДлинаЗначенияПеременнойДлины Цикл
Часть1 = РезультатРазбора.ОписаниеКода.Код
+ Лев(РезультатРазбора.ЗначениеПеременное, ВариантДлины);
Часть2 = Сред(РезультатРазбора.ЗначениеПеременное, ВариантДлины + 1);
...
Стек.Добавить(ЭлементСтека);
КонецЦикла
КонецЕсли;
КонецЦикла;
По-человечески: дойдя до поля переменной длины, алгоритм не угадывает границу. Он перебирает все возможные длины — от одного символа до максимально допустимой — и на каждую заводит отдельную ветку разбора. Каждая ветка досчитывается до конца сама по себе. Кто дошёл — попадает в результат.
Мелочь, которая меня развеселила: переменная называется Стек, а ведёт себя как очередь. Элемент берётся с начала (Стек[0], потом Стек.Удалить(0)), а новые ветки добавляются в конец — то есть это FIFO и обход в ширину, а не стек и не поиск с возвратом. На результат не влияет никак, все ветки всё равно будут пройдены. Но если полезете читать сами — не обманывайтесь именем, я обманулся.
Рис. 3 — перебор вариантов разбора
Я не ожидал найти в типовой полноценный перебор. Аккуратный, с копированием состояния ветки через СкопироватьРекурсивно, с отсечением заведомо мёртвых вариантов (Если ЗначениеЗаполнено(Часть2) И СтрДлина(Часть2) <= 2 Тогда Продолжить — хвост короче трёх символов не может быть идентификатором со значением). Кто-то в 1С сел и написал это всерьёз, понимая задачу.
И решение принципиально правильное. Раз информация утеряна — честный ответ «вот все прочтения, которые не противоречат формату», а не одно выдуманное с уверенным видом.
Для нас практический вывод неприятный: восстановить потерянный GS в общем случае нельзя. Можно перебрать варианты и надеяться, что уцелеет ровно один. На кодах с длинным криптохвостом — а 91, 92, 93 это до 90 символов каждый — надеяться особо не на что.
Где именно теряется разделитель
Дальше я потратил вечер на то, чтобы понять, кто конкретно виноват. Метод дурацкий, но рабочий: берёшь код со сканера, гоняешь через каждый маршрут по отдельности, сравниваешь длину и посимвольный дамп до и после.
XLSX — не подозреваемый, а приговор. Я сначала думал, что Excel «почему-то теряет» символ, и злился на Excel. Оказалось хуже и честнее: .xlsx — это XML внутри zip, а XML 1.0 запрещает управляющие символы с кодами 0–8, 11–12 и 14–31. GS с кодом 29 попадает ровно в запрещённый диапазон. Его туда физически нечем записать. Это не баг Excel и не его каприз — это свойство формата, и никакая настройка тут не поможет.
Проверил на всякий случай программно: библиотека записи xlsx на такой строке просто отказывается работать и говорит «cannot be used in worksheets». Excel в этой ситуации молча вычищает символ и сохраняет остальное — что для пользователя даже хуже, потому что выглядит как успех.
И — да, типовая про это знает тоже. В том же модуле разбора есть функция НайденНедопустимыйСимволXML, и в ней прямым текстом перечислено:
// Коды символов от 0 до 2^16-1, которые метод НайтиНедопустимыеСимволыXML
// считает недопустимыми: 0-8, 11-12, 14-31, 55296-57343.
Тот же диапазон. То есть код маркировки с разделителем — это строка, которую нельзя положить ни в XML, ни в xlsx, ни в любой обмен, который под капотом XML. Люди, писавшие подсистему, на эти грабли наступили и обвязались проверкой.
Заодно, раз уж речь про Excel: проверяйте GTIN на съеденные ведущие нули. Прогнал круг записи-чтения — 04600000000017 в числовой ячейке возвращается как 4600000000017. Ноль отвалился молча, длина стала 13 вместо 14. И на научную нотацию тоже смотрите, в неё Excel сворачивает всё длинное и цифровое.
CSV, вопреки ожиданиям, ни при чём. Я был уверен, что CSV портит не меньше, и погнал через него тот же код. Не портит: обычный CSV в UTF-8 переносит Символ(29) без потерь, на входе 43 знака и два разделителя, на выходе столько же. Так что если у вас есть выбор формата для передачи кодов — просите CSV, а не Excel. Ровно тот случай, когда старый скучный формат объективно лучше.
Осторожность с CSV нужна в другом месте — в алфавите допустимых символов. В типовой он перечислен явно (привожу выдержку, всего там четыре набора):
Алфавит.Вставить("БуквыЦифры", "ABC…XYZabc…xyz0123456789");
Алфавит.Вставить("БуквыЦифрыЗнаки", "ABC…XYZabc…xyz0123456789!”""%&’'()*+,-./_:;=<>?");
Алфавит.Вставить("БуквыЦифрыЗнакиМРЦ", "ABC…XYZabc…xyz0123456789!""%&'*+-./_,:;=<>?");
Алфавит.Вставить("Цифры", "0123456789");
Посмотрите на второй набор: кавычки, запятая, точка с запятой — ровно то, вокруг чего строится любой разбор CSV. Сам символ выживает, а вот наивный разбор строки по запятой на таком коде развалится.
Сканер. До файла дело может и не дойти. Передаёт сканер управляющие символы или нет — зависит от модели и настройки. Отсюда, кстати, растёт та самая фраза «на одном рабочем месте работает, на другом нет», которая звучит как мистика, а на деле просто разные настройки железа.
Чужая система в середине. Если код проходит через промежуточную систему, которая чистит поле от управляющих символов, вы получаете испорченные данные и чинить их у себя нечем. Только просить перевыгрузку.
Что я в итоге сделал
Вывод простой: файл с кодами надо проверять до того, как он поехал в базу. Не после, когда половина строк записалась, а до.
Минимальная проверка одной строки:
// Возвращает структуру с результатом проверки строки кода маркировки.
// Проверяется структурная целостность, не действительность кода в ГИС МТ.
Функция ПроверитьКодМаркировки(Знач Код) Экспорт
Результат = Новый Структура;
Результат.Вставить("Корректен", Истина);
Результат.Вставить("Предупреждения", Новый Массив);
Результат.Вставить("Представление", ВидимоеПредставление(Код));
Если Не ЗначениеЗаполнено(Код) Тогда
Результат.Корректен = Ложь;
Результат.Предупреждения.Добавить("Пустое значение");
Возврат Результат;
КонецЕсли;
Если Код <> СокрЛП(Код) Тогда
Результат.Предупреждения.Добавить("Пробелы по краям — обрезаны при проверке");
Код = СокрЛП(Код);
КонецЕсли;
// Код может прийти и в скобочной форме: (01)04600000000017(21)...
// Типовая её обрабатывает, значит и мы обязаны.
СоСкобками = СтрНачинаетсяС(Код, "(");
Если Не СоСкобками И Не СтрНачинаетсяС(Код, "01") Тогда
Результат.Корректен = Ложь;
Результат.Предупреждения.Добавить("Код не начинается с идентификатора 01 (GTIN)");
Возврат Результат;
КонецЕсли;
GTIN = ?(СоСкобками, Сред(Код, 5, 14), Сред(Код, 3, 14));
Если СтрДлина(GTIN) < 14 Или Не ТолькоЦифры(GTIN) Тогда
Результат.Корректен = Ложь;
Результат.Предупреждения.Добавить(
"GTIN повреждён: получено """ + GTIN + """. Частая причина — числовой формат ячейки Excel");
Возврат Результат;
КонецЕсли;
Если СтрНайти(Код, Символ(29)) = 0 Тогда
Результат.Корректен = Ложь;
Результат.Предупреждения.Добавить(
"В коде нет ни одного разделителя GS. Разбор будет неоднозначным");
КонецЕсли;
Возврат Результат;
КонецФункции
Функция ВидимоеПредставление(Знач Код)
Возврат СтрЗаменить(Код, Символ(29), "<GS>");
КонецФункции
Функция ТолькоЦифры(Знач Строка)
Для Позиция = 1 По СтрДлина(Строка) Цикл
Если СтрНайти("0123456789", Сред(Строка, Позиция, 1)) = 0 Тогда
Возврат Ложь;
КонецЕсли;
КонецЦикла;
Возврат Истина;
КонецФункции
Самое важное здесь — ВидимоеПредставление. Приём я честно украл у типовой: подменяем невидимое на видимое, прежде чем показать человеку. Пока пользователь видит строку «как есть», он будет уверен, что всё нормально, и будет спорить с программой. Я же спорил.
Последнюю проверку — на полное отсутствие GS — я специально сделал грубой. Точная проверка «после каждого поля переменной длины стоит разделитель» требует полного разбора по шаблонам вида продукции, а это работа типового механизма, и дублировать её незачем. Для отлова файла, побывавшего в Excel, грубой хватает с запасом: если во всех 2 470 строках нет ни одного разделителя — диагноз ставится за секунду.
Чего эта штука не делает
Действительность кода не проверяет. Существует ли он в ГИС МТ, не выведен ли из оборота, ваш ли он — ничего этого без обращения к «Честному ЗНАКу» не узнать. Здесь только структурная целостность строки. Код может быть безупречен по структуре и при этом полгода как продан.
Потерянный разделитель не восстанавливает. Как выше и разобрано — в общем случае это невозможно. Если GS потерялся на стороне поставщика, единственное рабочее решение — просить перевыгрузку в формате, который управляющие символы переживают.
И про стенд. Всё, что я показал из типового кода, — из выгрузки БП 3.0, релиз 3.0.111.25, учебная поставка. Релиз не свежий, и как та же логика выглядит в актуальных версиях с ТС ПИоТ, я не смотрел — там могло измениться многое. Но GS1 это внешний стандарт, а не изобретение 1С, и правило «поле переменной длины требует разделителя» от релиза конфигурации не зависит.
Проверить у себя — минута: откройте свою конфигурацию, найдите общий модуль РазборКодаМаркировкиИССлужебныйКлиентСервер и посмотрите, есть ли там РазделительGS и ВариантыРазбораШтрихкодаGS1БезРазделителей. Если есть — всё описанное про вас.
Осадок
Задача, которая звучала как «1С не принимает коды», оказалась задачей про один невидимый символ, потерянный по дороге. Программа была права. Файл был испорчен. А человек не мог этого увидеть, потому что смотреть было буквально не на что.
Больше всего меня в этой истории цепляет вот что. Я потратил полдня, а прямо в конфигурации, которая была открыта у меня в соседнем окне, лежала функция с ответом. И приём с подменой Символ(29) на <GS> в тексте ошибки тоже лежал — то есть кто-то этот путь прошёл раньше меня и оставил указатель. Я просто не догадался посмотреть.
С тех пор, когда упираюсь в непонятное поведение вокруг ГосИС, лезу в типовую первым делом, а не последним. Там, конечно, тринадцать тысяч строк в одном модуле и разбираться неприятно. Но написано это не идиотами, и обычно к нужному месту уже кто-то ходил.
А отвечать клиенту в итоге пришлось так: программа исправна, файл испорчен, просите поставщика перевыгрузить. Формулировка, которую никто не любит слышать, — поэтому к ней теперь прилагается скриншот с посимвольным дампом. Против <GS> красным по белому не поспоришь.
Сейчас собираю всё это в обработку проверки файла с кодами до загрузки — построчный разбор, подсветка найденного, режим проверки без записи. Если сталкивались с такими файлами и знаете способы их испортить, которых нет в моём списке, — напишите, добавлю в проверки. Особенно интересны экзотические выгрузки: у меня подозрение, что Excel тут не самый изобретательный.
Вступайте в нашу телеграмм-группу Инфостарт
Рис. 1 — посимвольный дамп: со сканера и после Excel