Загрузка прайса поставила цены и фото на чужие карточки: что делает «найти по артикулу», когда артикул в базе не один
Интеграция - Внешние источники данных
Что увидел заказчик
Заказ был обычный: раз в месяц поставщик присылает прайс на 35 тысяч строк, из него нужно обновить закупочные цены и цены сайта, завести новые товары и подгрузить фотографии. База на «Управлении торговлей» 11.5, в справочнике 41 322 карточки номенклатуры.
Загрузку я написал, проверил на копии, заказчик запустил её у себя. Через два дня он прислал снимок подбора. По коду 236671 там две строки: блокнот и настольная лампа. У блокнота стоит цена лампы. Чуть позже он прислал карточку блокнота, и в ней была фотография лампы.
Заметил это заказчик, а не я.
Причина
Загрузка искала товар по двум ключам по очереди: код для поиска, затем артикул. Чтобы не делать запрос на каждую строку прайса, я заранее строил индекс: читал справочник и складывал в соответствие пары «ключ, ссылка на карточку». Выглядело это так:
Если Индексы.ПоКоду[Код] = Неопределено Тогда
Индексы.ПоКоду.Вставить(Код, Выборка.Ссылка);
КонецЕсли;
Если ключ встретился второй раз, вторая карточка в индекс не попадала. Оставалась первая, которую вернул запрос. В запросе не было упорядочивания, так что какая из двух окажется первой, заранее сказать нельзя.
У лампы и блокнота совпал код для поиска. Индекс вернул блокнот, и на него легла вся строка прайса лампы: закупочная цена, цена сайта и фотография.
Я посмотрел, насколько это частый случай в той базе. Для статьи перемерил на копии:
| Поле | Значений, которые повторяются | Карточек с ними |
|---|---|---|
| Артикул | 329 | 880 |
| Код для поиска | 79 | 158 |
Из 41 тысячи карточек это немного. Но загрузка идёт по всему прайсу, и каждое такое значение, встретившееся в файле, означало бросок монеты.
Три сигнала, которые я прочитал неправильно
Самое неприятное в этой истории то, что все данные были у меня на руках до сдачи.
Первый сигнал. Перед загрузкой я сам снял отчёт по дублям кодов в базе. В нём было написано: кодов, встречающихся больше одного раза, 79. Я прочитал это как факт о данных заказчика. Вопрос «а что сделает моя загрузка, когда встретит такой код» я себе не задал.
Второй сигнал. В коде загрузки была защита от повторяющихся артикулов. Только ловила она повторы в прайсе: две строки файла с одним артикулом. Обратную сторону, два товара в базе с одним артикулом, никто не проверял.
Третий сигнал. Журнал загрузки был полон предупреждений «штрихкод уже закреплён за другим товаром». Это и был симптом: по коду найден один товар, а штрихкод из той же строки прайса принадлежит другому. Я записал эти сообщения как информационные.
И тесты. Они проверяли счётчики: сколько создано, сколько обновлено, сколько ошибок. Ни один тест не проверял, на ту ли карточку встала цена.
Как чинили загрузку
Правило я поменял на такое: если по ключу найдено несколько карточек, спор решается штрихкодом из той же строки прайса. Штрихкод у товара свой, и в первом поиске он не участвовал. Если и штрихкод не помог, ключ не используется вовсе, а строка уходит в список спорных, которые решает человек.
Такая строка требует минуты внимания человека, зато цена на чужой товар не попадает.
Как откатывали цены
Разбор после инцидента нашёл 77 пострадавших карточек и 154 записи цен. Все записи сделал один документ установки цен, который создала загрузка.
Первая мысль была выпустить встречный документ с прежними ценами. От неё пришлось отказаться: у 49 записей прежней цены не было вовсе. Товар раньше стоял без цены, а встречный документ поставил бы ему ноль. Ноль и отсутствие цены в учёте разные вещи.
Поэтому чинили иначе: удалили неверные строки из самого документа загрузки и перепровели его. Регистр цен периодический и подчинён документу, так что после удаления строки последней записью по товару снова становится та, что была до загрузки. Если до загрузки цены не было, её и не остаётся. В итоге 105 записей вернулись к прежней цене, 49 остались без цены.
С фотографиями поступили так. Там, где до загрузки у карточки была своя фотография, её вернули из копии базы. Там, где фотографии не было, чужую просто удалили.
Что возвращают методы поиска, когда значение не одно
Загрузка у клиента строила свой индекс, но тот же вопрос стоит перед любым кодом, который ищет карточку методами менеджера справочника. По описанию НайтиПоКоду, НайтиПоНаименованию и НайтиПоРеквизиту ищут элемент и возвращают ссылку на него. Что будет, если подходящих элементов два, я проверил прогоном.
Стенд: демонстрационная база «Комплексной автоматизации» 2.5.27.75, файловый вариант, платформа 8.5.1.1302. Тестовые карточки номенклатуры создавались в транзакции, которая в конце отменялась: до прогона в справочнике было 480 элементов, после отмены тоже 480. Чтобы не пересечься с данными базы, к наименованиям, артикулам и кодам добавлялась метка времени, в примерах ниже я её опускаю.
Я завёл две пары карточек. Внутри пары у карточек одинаковые наименование, артикул и код. Ссылки задавал сам: в паре А первой создавалась карточка со старшей ссылкой, в паре Б первой создавалась карточка с младшей. Запись шла так:
Об = Справочники.Номенклатура.СоздатьЭлемент();
Об.УстановитьСсылкуНового(Справочники.Номенклатура.ПолучитьСсылку(Новый УникальныйИдентификатор(Уид)));
Об.Наименование = Наименование;
Об.Артикул = Артикул;
Об.Код = Код;
Об.ОбменДанными.Загрузка = Истина;
Об.Записать();
Каждый метод я вызвал трижды по каждой паре:
| Вызов | Пара А: раньше создана старшая ссылка | Пара Б: раньше создана младшая ссылка |
|---|---|---|
НайтиПоРеквизиту("Артикул", Артикул) |
вторая по созданию, младшая ссылка | первая по созданию, младшая ссылка |
НайтиПоКоду(Код) |
вторая по созданию, младшая ссылка | первая по созданию, младшая ссылка |
НайтиПоНаименованию(Наименование, Истина) |
вторая по созданию, младшая ссылка | первая по созданию, младшая ссылка |
Все три метода вернули одну карточку и ничем не показали, что была вторая. Какую именно, решила ссылка: в обеих парах вернулась карточка с младшей ссылкой, хотя в одной паре она создана раньше, а в другой позже. Три вызова подряд дали один и тот же результат.
Для загрузки отсюда два вывода. Карточку выбирает не смысл и не порядок заведения товара, а значение ссылки, которого в рабочей базе никто не видит. И выбор устойчивый: каждая следующая загрузка положит строку на ту же самую чужую карточку. У клиента блокнот получал бы цену лампы каждый месяц, и сверка «после прошлой загрузки ничего не сломалось» этого бы не показала.
Запрос с условием Артикул = &Артикул по тем же ключам вернул обе карточки в каждой паре.
Ещё одно наблюдение из того же прогона. У номенклатуры в этой базе включён контроль уникальности кода, но карточки с одинаковым кодом записались: запись шла в режиме обмена данными, с ОбменДанными.Загрузка = Истина. Значит, одинаковый код в справочнике возможен и при включённом контроле.
НайтиПоНаименованию без второго параметра ищет по началу строки
В том же прогоне была карточка «Ручка синяя», а карточки с наименованием ровно «Ручка» не было. Поиск дал такие результаты:
Справочники.Номенклатура.НайтиПоНаименованию("Ручка"); // нашлась «Ручка синяя»
Справочники.Номенклатура.НайтиПоНаименованию("Ручка", Истина); // не нашлось ничего
Без второго параметра метод нашёл карточку, наименование которой только начинается с искомой строки. С параметром Истина не нашёл ничего, как и должен. Загрузка, которая ищет по наименованию и не передаёт Истина, при отсутствии точного совпадения обновит карточку, чьё наименование только начинается с искомого: по строке «Лампа» это может оказаться «Лампа настольная». Как метод выбирает, если в базе есть и точное совпадение, и более длинные наименования, я не проверял.
Пометка удаления не убирает карточку из поиска
Частый совет при дублях: пометить лишнюю карточку на удаление. Я проверил, увидит ли её после этого НайтиПоРеквизиту. Ещё две пары с одинаковым артикулом, в каждой одна карточка помечена на удаление:
| Пара | Какая карточка помечена | Что вернул НайтиПоРеквизиту |
|---|---|---|
| Е | с младшей ссылкой | помеченную |
| Ж | со старшей ссылкой | непомеченную |
Пометка на выбор не повлияла: метод снова вернул карточку с младшей ссылкой, и в паре Е это оказалась помеченная на удаление. Пометка лишней карточки помогает только тому коду, который сам отбрасывает помеченные, например запросом с условием НЕ ПометкаУдаления. Код с НайтиПоРеквизиту она не спасает.
Пробел в начале артикула
Пока я разбирался с дублями, нашёлся способ промахнуться в другую сторону. Загрузка берёт значение из файла, обрезает пробелы по краям и ищет в базе. Если в базе артикул записан с пробелом в начале, карточка не находится, и загрузка создаёт новую, с тем же артикулом без пробела. В справочнике становится две карточки, и следующая загрузка уже выбирает между ними.
В прогоне карточка с артикулом « СТП» по ключу «СТП» не нашлась ни через НайтиПоРеквизиту, ни запросом. Карточка с артикулом «ЛИН» и двумя пробелами в конце по ключу без пробелов нашлась обоими способами. Пробел в конце безвреден, пробел в начале превращает артикул в другой.
На копии рабочей базы клиента карточек с пробелом в начале артикула четыре.
Как искать, чтобы видеть все совпадения
Индекс, который видит все совпадения, отличается от исходного немногим. Под ключом лежит не одна ссылка, а массив всех карточек. Помеченные на удаление в индекс не попадают. Ключ из базы обрезается так же, как значение из файла:
Запрос = Новый Запрос(
"ВЫБРАТЬ
| Т.Ссылка КАК Ссылка,
| Т.Артикул КАК Артикул
|ИЗ
| Справочник.Номенклатура КАК Т
|ГДЕ
| НЕ Т.ПометкаУдаления
| И НЕ Т.ЭтоГруппа
| И Т.Артикул <> """"");
Выборка = Запрос.Выполнить().Выбрать();
ПоАртикулу = Новый Соответствие;
Пока Выборка.Следующий() Цикл
Ключ = СокрЛП(Выборка.Артикул);
Найденные = ПоАртикулу[Ключ];
Если Найденные = Неопределено Тогда
Найденные = Новый Массив;
ПоАртикулу.Вставить(Ключ, Найденные);
КонецЕсли;
Найденные.Добавить(Выборка.Ссылка);
КонецЦикла;
При загрузке строки решение принимается по числу карточек под ключом:
Найденные = ПоАртикулу[СокрЛП(АртикулИзФайла)];
Если Найденные = Неопределено Тогда
// карточки нет: создать новую
ИначеЕсли Найденные.Количество() = 1 Тогда
// обновить Найденные[0]
Иначе
// ключ неоднозначен: строку не записывать, в журнал перечислить все карточки
КонецЕсли;
Этот индекс я прогнал на тех же тестовых карточках. По артикулу пары А он ответил «ключ неоднозначен» и перечислил обе карточки. В паре Е взял непомеченную. Карточку с пробелом в начале артикула нашёл, потому что ключ из базы тоже обрезается. По артикулу, которого в базе нет, ответил «создать новую».
Как я теперь проверяю загрузки
Раньше тест загрузки заканчивался сверкой счётчиков: сколько создано, сколько обновлено. Теперь в нём есть карточки-ловушки: две карточки с одним артикулом, карточка, помеченная на удаление, рядом с обычной, карточка с пробелом в начале артикула. После загрузки тест читает каждую из них по ссылке и сверяет реквизиты. Условие звучит так: обе карточки с повторяющимся артикулом остались прежними, обновилась ровно та карточка, которая должна была, и никакая другая.
Всё идёт в транзакции, которая в конце отменяется, и тест сверяет число карточек до начала и после отмены. Так устроен и прогон из этой статьи: 480 карточек до, 480 после.
Что делать с повторами в базе
Остановленная строка это сообщение о том, что в справочнике два товара с одним ключом. Варианты такие.
Выбрать для поиска другое поле, значения которого не повторяются. На копии базы клиента артикул повторяется 329 раз, код для поиска 79 раз, а код справочника один раз, и то у двух карточек.
Пометить лишнюю карточку на удаление, если поиск сам отбрасывает помеченные, как индекс выше. С НайтиПоРеквизиту это не работает: метод может вернуть именно помеченную.
Объединить дубли. В «Управлении торговлей» для этого есть штатная обработка «Поиск и удаление дублей». После объединения ключ снова принадлежит одной карточке.
Чего делать не стоит: возвращать «первый найденный», чтобы строки перестали останавливаться. С этого история с лампой и началась.
Как проверить свою базу
Повторы артикула среди непомеченных карточек:
ВЫБРАТЬ
Т.Артикул КАК Артикул,
КОЛИЧЕСТВО(*) КАК Карточек
ИЗ
Справочник.Номенклатура КАК Т
ГДЕ
НЕ Т.ПометкаУдаления
И НЕ Т.ЭтоГруппа
И Т.Артикул <> ""
СГРУППИРОВАТЬ ПО
Т.Артикул
ИМЕЮЩИЕ
КОЛИЧЕСТВО(*) > 1
УПОРЯДОЧИТЬ ПО
Карточек УБЫВ
На копии базы клиента запрос вернул 329 строк, в сумме 880 карточек. Вместо артикула подставьте то поле, по которому ваши загрузки и обмены ищут товар.
Артикулы, записанные с пробелом в начале:
ВЫБРАТЬ
Т.Ссылка КАК Ссылка,
Т.Артикул КАК Артикул
ИЗ
Справочник.Номенклатура КАК Т
ГДЕ
Т.Артикул ПОДОБНО " %"
И НЕ Т.ПометкаУдаления
И НЕ Т.ЭтоГруппа
На копии базы клиента он вернул 4 строки.
Если первый запрос что-то вернул, посмотрите, как ваша загрузка или обмен ищет товар. НайтиПоКоду, НайтиПоНаименованию и НайтиПоРеквизиту при повторе вернут одну карточку и ничего не скажут о второй. Так же ведёт себя соответствие, заполненное в цикле без проверки на повтор.
Чего я не проверял
- Методы поиска проверены на файловой базе. На клиент-серверной базе с MS SQL или PostgreSQL я этот прогон не делал: какая карточка из пары вернётся там, не знаю.
- То, что вернулась карточка с младшей ссылкой, видно на двух парах и одной версии платформы. Это наблюдение, а не правило из документации, и строить на нём выбор карточки нельзя.
- Цифры про 77 карточек, 154 записи цен и 49 записей без прежней цены взяты из разбора в день инцидента. Перемерить их сегодня нельзя: копию базы тогда же и починили.
Вступайте в нашу телеграмм-группу Инфостарт
Вы можете заказать платную адаптацию этой статьи под ваши задачи на «Бирже заказов».
- 0% комиссии — оплата напрямую исполнителю;
- Исполнители любого масштаба — от отдельных специалистов до команд под проект;
- Прямой обмен контактами между заказчиком и исполнителем;
- Безопасная сделка — при необходимости;
- Рейтинги, кейсы и прозрачная система откликов.