diff --git a/data/catalog/products.csv b/data/catalog/products.csv index 5f72864..63ebfbf 100644 --- a/data/catalog/products.csv +++ b/data/catalog/products.csv @@ -1,181 +1,181 @@ -sku,name,category,brand,price -HOME-0001,Комплект органайзеров для шкафа,Товары для дома,Домовит,129000 -HOME-0002,Сушилка для белья напольная,Товары для дома,Домовит,249900 -HOME-0003,Настольная лампа «Тихий вечер»,Товары для дома,Ладный дом,188990 -HOME-0004,Набор вешалок 12 штук,Товары для дома,Домовит,79000 -TEXT-0001,Комплект постельного белья полутораспальный,Текстиль,Сатин,348990 -TEXT-0002,Плед-покрывало 200×220,Текстиль,Сатин,279000 -TEXT-0003,Полотенце банное 70×140,Текстиль,Сатин,89000 -TEXT-0004,Штора блэкаут 200×260,Текстиль,Ладный дом,218990 -POSU-0001,Сковорода 26 см с антипригарным покрытием,Посуда,Кухмастер,198990 -POSU-0002,Набор кастрюль 3 предмета,Посуда,Кухмастер,458999 -POSU-0003,Чайник заварочный стеклянный 1 л,Посуда,Кухмастер,118990 -POSU-0004,Набор столовых приборов 24 предмета,Посуда,Ладья,289000 -TECH-0001,Электрочайник 1.7 л,Бытовая техника,Электрон,188990 -TECH-0002,Блендер погружной 800 Вт,Бытовая техника,Электрон,279000 -TECH-0003,Отпариватель ручной,Бытовая техника,Электрон,349000 -TECH-0004,Робот-пылесос,Бытовая техника,Электрон,1899000 -KIDS-0001,Стульчик для кормления,Детские товары,Малышок,649000 -KIDS-0002,Ночник-проектор «Звёзды»,Детские товары,Малышок,128990 -KIDS-0003,Набор детской посуды 5 предметов,Детские товары,Малышок,88999 -WEAR-0001,Тапочки домашние войлочные,Одежда и обувь,Ходики,98990 -WEAR-0002,Халат махровый,Одежда и обувь,Сатин,249000 -WEAR-0003,Носки хлопковые 5 пар,Одежда и обувь,Ходики,59000 -HOME-0005,Корзина для белья 45 л,Товары для дома,Домовит,119000 -HOME-0006,Контейнер для хранения 18 л,Товары для дома,Домовит,69000 -HOME-0007,Чехол для одежды 60×100,Товары для дома,Ладный дом,38990 -HOME-0008,Коврик придверный резиновый 40×60,Товары для дома,Домовит,59000 -HOME-0009,Гладильная доска настольная 73×30,Товары для дома,Ладный дом,149000 -HOME-0010,Ведро хозяйственное с крышкой 12 л,Товары для дома,Домовит,48990 -HOME-0011,Швабра с отжимом и ведром 8 л,Товары для дома,Домовит,178990 -HOME-0012,Щётка для пола с черенком 120 см,Товары для дома,Домовит,45000 -HOME-0013,Совок со щёткой на длинной ручке,Товары для дома,Домовит,79000 -HOME-0014,Таз хозяйственный круглый 14 л,Товары для дома,Домовит,38990 -HOME-0015,Этажерка для ванной 3 яруса,Товары для дома,Ладный дом,139000 -HOME-0016,Полка настенная металлическая 45 см,Товары для дома,Ладный дом,99000 -HOME-0017,Зеркало настенное круглое 50 см,Товары для дома,Ладный дом,168990 -HOME-0018,Набор коробок для обуви 6 штук,Товары для дома,Домовит,129000 -HOME-0019,Подставка для зонтов металлическая,Товары для дома,Ладный дом,158990 -HOME-0020,Крючки самоклеящиеся 8 штук,Товары для дома,Домовит,29000 -HOME-0021,Верёвка бельевая 20 м,Товары для дома,Домовит,19000 -HOME-0022,Прищепки усиленные 24 штуки,Товары для дома,Домовит,24990 -HOME-0023,Мешки вакуумные для одежды 5 штук,Товары для дома,Домовит,68999 -HOME-0024,Органайзер подвесной с 12 карманами,Товары для дома,Ладный дом,79000 -HOME-0025,Часы настенные круглые 30 см,Товары для дома,Ладный дом,88990 -HOME-0026,Светильник настольный с прищепкой 7 Вт,Товары для дома,Ладный дом,119000 -HOME-0027,Удлинитель бытовой 3 розетки 3 м,Товары для дома,Ладный дом,99000 -HOME-0028,Лестница-стремянка 3 ступени,Товары для дома,Домовит,299000 -HOME-0029,Ящик для инструментов 16 дюймов,Товары для дома,Домовит,138990 -HOME-0030,Набор плечиков для брюк 6 штук,Товары для дома,Домовит,89000 -TEXT-0005,Пододеяльник двуспальный 175×215,Текстиль,Сатин,169000 -TEXT-0006,Простыня на резинке 160×200,Текстиль,Сатин,148990 -TEXT-0007,Наволочки хлопковые 50×70 2 штуки,Текстиль,Сатин,79000 -TEXT-0008,Одеяло всесезонное 172×205,Текстиль,Сатин,299000 -TEXT-0009,Подушка стёганая 50×70,Текстиль,Сатин,128990 -TEXT-0010,Наматрасник непромокаемый 160×200,Текстиль,Сатин,189000 -TEXT-0011,Покрывало стёганое 180×210,Текстиль,Сатин,238999 -TEXT-0012,Плед флисовый 150×200,Текстиль,Сатин,118990 -TEXT-0013,Полотенце лицевое 50×90,Текстиль,Сатин,49000 -TEXT-0014,Полотенца кухонные 40×60 3 штуки,Текстиль,Сатин,59000 -TEXT-0015,Коврик для ванной хлопковый 50×80,Текстиль,Ладный дом,99000 -TEXT-0016,Штора для ванной 180×200,Текстиль,Ладный дом,79000 -TEXT-0017,Тюль вуаль 300×260,Текстиль,Ладный дом,199000 -TEXT-0018,Шторы кухонные 150×170 2 полотна,Текстиль,Ладный дом,179000 -TEXT-0019,Скатерть водоотталкивающая 140×180,Текстиль,Ладный дом,129000 -TEXT-0020,Дорожка на стол хлопковая 40×140,Текстиль,Ладный дом,59000 -TEXT-0021,Салфетки столовые льняные 4 штуки,Текстиль,Сатин,79000 -TEXT-0022,Чехлы на табурет 35×35 4 штуки,Текстиль,Ладный дом,108990 -TEXT-0023,Подушка декоративная 40×40,Текстиль,Сатин,68999 -TEXT-0024,Чехол для диванной подушки 45×45,Текстиль,Сатин,39000 -TEXT-0025,Фартук кухонный с карманом,Текстиль,Ладный дом,59000 -TEXT-0026,Прихватки стёганые 20×20 2 штуки,Текстиль,Ладный дом,38990 -TEXT-0027,Халат вафельный размер 48–50,Текстиль,Сатин,199000 -TEXT-0028,Комплект полотенец 50×90 и 70×140,Текстиль,Сатин,138999 -TEXT-0029,Одеяло детское байковое 100×140,Текстиль,Сатин,169000 -TEXT-0030,Подушка для стула 40×40,Текстиль,Ладный дом,59000 -POSU-0005,Кастрюля эмалированная 3 л,Посуда,Кухмастер,168990 -POSU-0006,Ковш нержавеющий с крышкой 1.5 л,Посуда,Кухмастер,138990 -POSU-0007,Сотейник литой с крышкой 28 см,Посуда,Кухмастер,269000 -POSU-0008,Сковорода блинная 22 см,Посуда,Кухмастер,149000 -POSU-0009,Казан алюминиевый 6 л,Посуда,Кухмастер,299000 -POSU-0010,Форма для запекания стеклянная 2.4 л,Посуда,Кухмастер,118990 -POSU-0011,Противень стальной 37×27,Посуда,Кухмастер,79000 -POSU-0012,Набор форм для выпечки 3 штуки,Посуда,Кухмастер,139000 -POSU-0013,Миска нержавеющая 24 см,Посуда,Кухмастер,68990 -POSU-0014,Дуршлаг стальной с ручками 22 см,Посуда,Кухмастер,89000 -POSU-0015,Тёрка четырёхгранная 23 см,Посуда,Кухмастер,49000 -POSU-0016,Доска разделочная бамбуковая 35×25,Посуда,Кухмастер,78990 -POSU-0017,Набор ножей с подставкой 6 предметов,Посуда,Кухмастер,248990 -POSU-0018,Половник и шумовка 2 предмета,Посуда,Кухмастер,58990 -POSU-0019,Контейнеры пищевые 7 предметов,Посуда,Кухмастер,99000 -POSU-0020,Банки для сыпучих продуктов 4 штуки,Посуда,Ладья,138990 -POSU-0021,Тарелки обеденные фарфоровые 6 штук,Посуда,Ладья,178999 -POSU-0022,Тарелки суповые керамические 4 штуки,Посуда,Ладья,129000 -POSU-0023,Салатник стеклянный 23 см,Посуда,Ладья,68999 -POSU-0024,Кружки керамические 350 мл 4 штуки,Посуда,Ладья,118990 -POSU-0025,Стаканы высокие 400 мл 6 штук,Посуда,Ладья,98990 -POSU-0026,Бокалы для воды 300 мл 6 штук,Посуда,Ладья,149000 -POSU-0027,Термос стальной 1 л,Посуда,Ладья,179000 -POSU-0028,Термокружка с крышкой 450 мл,Посуда,Ладья,99000 -POSU-0029,Сахарница фарфоровая 400 мл,Посуда,Ладья,59000 -POSU-0030,Маслёнка стеклянная с крышкой,Посуда,Ладья,68990 -TECH-0005,Миксер ручной 500 Вт,Бытовая техника,Электрон,248990 -TECH-0006,Измельчитель с чашей 1.5 л,Бытовая техника,Электрон,298990 -TECH-0007,Стационарный блендер 1.8 л,Бытовая техника,Электрон,549000 -TECH-0008,Мясорубка электрическая 1800 Вт,Бытовая техника,Электрон,799000 -TECH-0009,Тостер на 2 ломтика 850 Вт,Бытовая техника,Электрон,299000 -TECH-0010,Вафельница электрическая 1000 Вт,Бытовая техника,Электрон,348999 -TECH-0011,Мультиварка с чашей 5 л,Бытовая техника,Электрон,699000 -TECH-0012,Электроплитка на 1 конфорку 1500 Вт,Бытовая техника,Электрон,249000 -TECH-0013,Мини-печь с грилем 30 л,Бытовая техника,Электрон,899000 -TECH-0014,Микроволновая печь 20 л,Бытовая техника,Электрон,999000 -TECH-0015,Сушилка для овощей 5 поддонов,Бытовая техника,Электрон,499000 -TECH-0016,Кофемолка электрическая 200 Вт,Бытовая техника,Электрон,199000 -TECH-0017,Капельная кофеварка 1.2 л,Бытовая техника,Электрон,399000 -TECH-0018,Соковыжималка шнековая 150 Вт,Бытовая техника,Электрон,799000 -TECH-0019,Утюг паровой 2400 Вт,Бытовая техника,Электрон,349000 -TECH-0020,Швейная машина 12 операций,Бытовая техника,Электрон,1299000 -TECH-0021,Пылесос контейнерный 2000 Вт,Бытовая техника,Электрон,1099000 -TECH-0022,Пылесос вертикальный аккумуляторный,Бытовая техника,Электрон,1499000 -TECH-0023,Электровеник с контейнером 0.5 л,Бытовая техника,Электрон,699000 -TECH-0024,Фен с диффузором 2200 Вт,Бытовая техника,Электрон,299000 -TECH-0025,Щипцы для волос 45 Вт,Бытовая техника,Электрон,249000 -TECH-0026,Машинка для стрижки с 6 насадками,Бытовая техника,Электрон,399000 -TECH-0027,Весы напольные до 180 кг,Бытовая техника,Электрон,198990 -TECH-0028,Весы кухонные до 5 кг,Бытовая техника,Электрон,148990 -TECH-0029,Увлажнитель воздуха 3 л,Бытовая техника,Электрон,499000 -TECH-0030,Тепловентилятор керамический 1500 Вт,Бытовая техника,Электрон,398990 -KIDS-0004,Кроватка кукольная деревянная 45 см,Детские товары,Малышок,248999 -KIDS-0005,Манеж складной 100×100,Детские товары,Малышок,798990 -KIDS-0006,Горшок детский со спинкой,Детские товары,Малышок,79000 -KIDS-0007,Ванночка детская 84 см,Детские товары,Малышок,168990 -KIDS-0008,Подставка детская двухступенчатая,Детские товары,Малышок,89000 -KIDS-0009,Накладка на унитаз с ручками,Детские товары,Малышок,69000 -KIDS-0010,Сиденье для купания на присосках,Детские товары,Малышок,138990 -KIDS-0011,Пелёнки муслиновые 80×100 3 штуки,Детские товары,Малышок,129000 -KIDS-0012,Одеяло для коляски 90×110,Детские товары,Малышок,148990 -KIDS-0013,Нагрудники непромокаемые 5 штук,Детские товары,Малышок,58999 -KIDS-0014,Бутылочка с ручками 250 мл,Детские товары,Малышок,49000 -KIDS-0015,Поильник непроливайка 300 мл,Детские товары,Малышок,54990 -KIDS-0016,Контейнер для пустышки 2 штуки,Детские товары,Малышок,25000 -KIDS-0017,Погремушки мягкие 3 штуки,Детские товары,Малышок,59000 -KIDS-0018,Кубики развивающие 12 штук,Детские товары,Малышок,89000 -KIDS-0019,Пирамидка деревянная 8 колец,Детские товары,Малышок,78990 -KIDS-0020,Сортер с геометрическими фигурами,Детские товары,Малышок,98990 -KIDS-0021,Конструктор деревянный 50 деталей,Детские товары,Малышок,139000 -KIDS-0022,Доска для рисования магнитная 30×40,Детские товары,Малышок,128990 -KIDS-0023,Мольберт детский двусторонний 105 см,Детские товары,Малышок,298999 -KIDS-0024,Набор для лепки 12 цветов,Детские товары,Малышок,69000 -KIDS-0025,Коврик игровой складной 180×150,Детские товары,Малышок,399000 -KIDS-0026,Палатка игровая с окошками,Детские товары,Малышок,199000 -KIDS-0027,Каталка с ручкой и звуком,Детские товары,Малышок,169000 -KIDS-0028,Самокат трёхколёсный до 50 кг,Детские товары,Малышок,298999 -KIDS-0029,Санки со спинкой и ремнём,Детские товары,Малышок,349000 -KIDS-0030,Рюкзак детский объёмом 8 л,Детские товары,Малышок,128990 -WEAR-0004,Футболка хлопковая размер 48,Одежда и обувь,Сатин,78990 -WEAR-0005,Майка хлопковая размер 46,Одежда и обувь,Сатин,58990 -WEAR-0006,Лонгслив трикотажный размер 50,Одежда и обувь,Сатин,108990 -WEAR-0007,Водолазка хлопковая размер 44,Одежда и обувь,Сатин,118990 -WEAR-0008,Толстовка с капюшоном размер 52,Одежда и обувь,Сатин,198999 -WEAR-0009,Брюки спортивные размер 50,Одежда и обувь,Сатин,169000 -WEAR-0010,Легинсы хлопковые размер 46,Одежда и обувь,Сатин,98990 -WEAR-0011,Пижама трикотажная размер 48,Одежда и обувь,Сатин,179000 -WEAR-0012,Ночная сорочка хлопковая размер 50,Одежда и обувь,Сатин,128999 -WEAR-0013,Фартук рабочий с тремя карманами,Одежда и обувь,Сатин,79000 -WEAR-0014,Перчатки хозяйственные 3 пары,Одежда и обувь,Ходики,39000 -WEAR-0015,Варежки вязаные размер 8,Одежда и обувь,Ходики,68990 -WEAR-0016,Шапка вязаная с отворотом размер 56,Одежда и обувь,Ходики,88990 -WEAR-0017,Шарф шерстяной 30×180,Одежда и обувь,Ходики,119000 -WEAR-0018,Ремень текстильный длиной 120 см,Одежда и обувь,Ходики,78990 -WEAR-0019,Стельки войлочные 2 пары,Одежда и обувь,Ходики,38990 -WEAR-0020,Шнурки круглые 120 см 3 пары,Одежда и обувь,Ходики,25000 -WEAR-0021,Кеды текстильные размер 42,Одежда и обувь,Ходики,179000 -WEAR-0022,Кроссовки прогулочные размер 39,Одежда и обувь,Ходики,248990 -WEAR-0023,Ботинки утеплённые размер 41,Одежда и обувь,Ходики,348990 -WEAR-0024,Сапоги резиновые размер 40,Одежда и обувь,Ходики,199000 -WEAR-0025,Полуботинки кожаные размер 43,Одежда и обувь,Ходики,398999 -WEAR-0026,Сандалии с ремешками размер 38,Одежда и обувь,Ходики,189000 -WEAR-0027,Сланцы резиновые размер 41,Одежда и обувь,Ходики,79000 -WEAR-0028,Балетки текстильные размер 37,Одежда и обувь,Ходики,149000 -WEAR-0029,Туфли домашние утеплённые размер 39,Одежда и обувь,Ходики,129000 -WEAR-0030,Галоши садовые размер 42,Одежда и обувь,Ходики,98990 +sku,name,category,brand,price,demand +HOME-0001,Комплект органайзеров для шкафа,Товары для дома,Домовит,129000,обычный +HOME-0002,Сушилка для белья напольная,Товары для дома,Домовит,249900,обычный +HOME-0003,Настольная лампа «Тихий вечер»,Товары для дома,Ладный дом,188990,магнит +HOME-0004,Набор вешалок 12 штук,Товары для дома,Домовит,79000,магнит +TEXT-0001,Комплект постельного белья полутораспальный,Текстиль,Сатин,348990,обычный +TEXT-0002,Плед-покрывало 200×220,Текстиль,Сатин,279000,залёживается +TEXT-0003,Полотенце банное 70×140,Текстиль,Сатин,89000,обычный +TEXT-0004,Штора блэкаут 200×260,Текстиль,Ладный дом,218990,залёживается +POSU-0001,Сковорода 26 см с антипригарным покрытием,Посуда,Кухмастер,198990,залёживается +POSU-0002,Набор кастрюль 3 предмета,Посуда,Кухмастер,458999,магнит +POSU-0003,Чайник заварочный стеклянный 1 л,Посуда,Кухмастер,118990,магнит +POSU-0004,Набор столовых приборов 24 предмета,Посуда,Ладья,289000,магнит +TECH-0001,Электрочайник 1.7 л,Бытовая техника,Электрон,188990,обычный +TECH-0002,Блендер погружной 800 Вт,Бытовая техника,Электрон,279000,залёживается +TECH-0003,Отпариватель ручной,Бытовая техника,Электрон,349000,обычный +TECH-0004,Робот-пылесос,Бытовая техника,Электрон,1899000,залёживается +KIDS-0001,Стульчик для кормления,Детские товары,Малышок,649000,магнит +KIDS-0002,Ночник-проектор «Звёзды»,Детские товары,Малышок,128990,магнит +KIDS-0003,Набор детской посуды 5 предметов,Детские товары,Малышок,88999,обычный +WEAR-0001,Тапочки домашние войлочные,Одежда и обувь,Ходики,98990,обычный +WEAR-0002,Халат махровый,Одежда и обувь,Сатин,249000,магнит +WEAR-0003,Носки хлопковые 5 пар,Одежда и обувь,Ходики,59000,залёживается +HOME-0005,Корзина для белья 45 л,Товары для дома,Домовит,119000,магнит +HOME-0006,Контейнер для хранения 18 л,Товары для дома,Домовит,69000,обычный +HOME-0007,Чехол для одежды 60×100,Товары для дома,Ладный дом,38990,залёживается +HOME-0008,Коврик придверный резиновый 40×60,Товары для дома,Домовит,59000,обычный +HOME-0009,Гладильная доска настольная 73×30,Товары для дома,Ладный дом,149000,залёживается +HOME-0010,Ведро хозяйственное с крышкой 12 л,Товары для дома,Домовит,48990,залёживается +HOME-0011,Швабра с отжимом и ведром 8 л,Товары для дома,Домовит,178990,обычный +HOME-0012,Щётка для пола с черенком 120 см,Товары для дома,Домовит,45000,обычный +HOME-0013,Совок со щёткой на длинной ручке,Товары для дома,Домовит,79000,магнит +HOME-0014,Таз хозяйственный круглый 14 л,Товары для дома,Домовит,38990,магнит +HOME-0015,Этажерка для ванной 3 яруса,Товары для дома,Ладный дом,139000,магнит +HOME-0016,Полка настенная металлическая 45 см,Товары для дома,Ладный дом,99000,обычный +HOME-0017,Зеркало настенное круглое 50 см,Товары для дома,Ладный дом,168990,залёживается +HOME-0018,Набор коробок для обуви 6 штук,Товары для дома,Домовит,129000,магнит +HOME-0019,Подставка для зонтов металлическая,Товары для дома,Ладный дом,158990,обычный +HOME-0020,Крючки самоклеящиеся 8 штук,Товары для дома,Домовит,29000,обычный +HOME-0021,Верёвка бельевая 20 м,Товары для дома,Домовит,19000,обычный +HOME-0022,Прищепки усиленные 24 штуки,Товары для дома,Домовит,24990,залёживается +HOME-0023,Мешки вакуумные для одежды 5 штук,Товары для дома,Домовит,68999,обычный +HOME-0024,Органайзер подвесной с 12 карманами,Товары для дома,Ладный дом,79000,обычный +HOME-0025,Часы настенные круглые 30 см,Товары для дома,Ладный дом,88990,магнит +HOME-0026,Светильник настольный с прищепкой 7 Вт,Товары для дома,Ладный дом,119000,обычный +HOME-0027,Удлинитель бытовой 3 розетки 3 м,Товары для дома,Ладный дом,99000,магнит +HOME-0028,Лестница-стремянка 3 ступени,Товары для дома,Домовит,299000,обычный +HOME-0029,Ящик для инструментов 16 дюймов,Товары для дома,Домовит,138990,обычный +HOME-0030,Набор плечиков для брюк 6 штук,Товары для дома,Домовит,89000,обычный +TEXT-0005,Пододеяльник двуспальный 175×215,Текстиль,Сатин,169000,обычный +TEXT-0006,Простыня на резинке 160×200,Текстиль,Сатин,148990,обычный +TEXT-0007,Наволочки хлопковые 50×70 2 штуки,Текстиль,Сатин,79000,залёживается +TEXT-0008,Одеяло всесезонное 172×205,Текстиль,Сатин,299000,обычный +TEXT-0009,Подушка стёганая 50×70,Текстиль,Сатин,128990,обычный +TEXT-0010,Наматрасник непромокаемый 160×200,Текстиль,Сатин,189000,магнит +TEXT-0011,Покрывало стёганое 180×210,Текстиль,Сатин,238999,обычный +TEXT-0012,Плед флисовый 150×200,Текстиль,Сатин,118990,обычный +TEXT-0013,Полотенце лицевое 50×90,Текстиль,Сатин,49000,обычный +TEXT-0014,Полотенца кухонные 40×60 3 штуки,Текстиль,Сатин,59000,обычный +TEXT-0015,Коврик для ванной хлопковый 50×80,Текстиль,Ладный дом,99000,залёживается +TEXT-0016,Штора для ванной 180×200,Текстиль,Ладный дом,79000,обычный +TEXT-0017,Тюль вуаль 300×260,Текстиль,Ладный дом,199000,обычный +TEXT-0018,Шторы кухонные 150×170 2 полотна,Текстиль,Ладный дом,179000,залёживается +TEXT-0019,Скатерть водоотталкивающая 140×180,Текстиль,Ладный дом,129000,обычный +TEXT-0020,Дорожка на стол хлопковая 40×140,Текстиль,Ладный дом,59000,залёживается +TEXT-0021,Салфетки столовые льняные 4 штуки,Текстиль,Сатин,79000,обычный +TEXT-0022,Чехлы на табурет 35×35 4 штуки,Текстиль,Ладный дом,108990,обычный +TEXT-0023,Подушка декоративная 40×40,Текстиль,Сатин,68999,магнит +TEXT-0024,Чехол для диванной подушки 45×45,Текстиль,Сатин,39000,обычный +TEXT-0025,Фартук кухонный с карманом,Текстиль,Ладный дом,59000,магнит +TEXT-0026,Прихватки стёганые 20×20 2 штуки,Текстиль,Ладный дом,38990,магнит +TEXT-0027,Халат вафельный размер 48–50,Текстиль,Сатин,199000,залёживается +TEXT-0028,Комплект полотенец 50×90 и 70×140,Текстиль,Сатин,138999,обычный +TEXT-0029,Одеяло детское байковое 100×140,Текстиль,Сатин,169000,обычный +TEXT-0030,Подушка для стула 40×40,Текстиль,Ладный дом,59000,обычный +POSU-0005,Кастрюля эмалированная 3 л,Посуда,Кухмастер,168990,обычный +POSU-0006,Ковш нержавеющий с крышкой 1.5 л,Посуда,Кухмастер,138990,залёживается +POSU-0007,Сотейник литой с крышкой 28 см,Посуда,Кухмастер,269000,магнит +POSU-0008,Сковорода блинная 22 см,Посуда,Кухмастер,149000,обычный +POSU-0009,Казан алюминиевый 6 л,Посуда,Кухмастер,299000,обычный +POSU-0010,Форма для запекания стеклянная 2.4 л,Посуда,Кухмастер,118990,магнит +POSU-0011,Противень стальной 37×27,Посуда,Кухмастер,79000,залёживается +POSU-0012,Набор форм для выпечки 3 штуки,Посуда,Кухмастер,139000,залёживается +POSU-0013,Миска нержавеющая 24 см,Посуда,Кухмастер,68990,залёживается +POSU-0014,Дуршлаг стальной с ручками 22 см,Посуда,Кухмастер,89000,залёживается +POSU-0015,Тёрка четырёхгранная 23 см,Посуда,Кухмастер,49000,обычный +POSU-0016,Доска разделочная бамбуковая 35×25,Посуда,Кухмастер,78990,магнит +POSU-0017,Набор ножей с подставкой 6 предметов,Посуда,Кухмастер,248990,обычный +POSU-0018,Половник и шумовка 2 предмета,Посуда,Кухмастер,58990,обычный +POSU-0019,Контейнеры пищевые 7 предметов,Посуда,Кухмастер,99000,залёживается +POSU-0020,Банки для сыпучих продуктов 4 штуки,Посуда,Ладья,138990,залёживается +POSU-0021,Тарелки обеденные фарфоровые 6 штук,Посуда,Ладья,178999,обычный +POSU-0022,Тарелки суповые керамические 4 штуки,Посуда,Ладья,129000,обычный +POSU-0023,Салатник стеклянный 23 см,Посуда,Ладья,68999,обычный +POSU-0024,Кружки керамические 350 мл 4 штуки,Посуда,Ладья,118990,обычный +POSU-0025,Стаканы высокие 400 мл 6 штук,Посуда,Ладья,98990,обычный +POSU-0026,Бокалы для воды 300 мл 6 штук,Посуда,Ладья,149000,обычный +POSU-0027,Термос стальной 1 л,Посуда,Ладья,179000,залёживается +POSU-0028,Термокружка с крышкой 450 мл,Посуда,Ладья,99000,залёживается +POSU-0029,Сахарница фарфоровая 400 мл,Посуда,Ладья,59000,магнит +POSU-0030,Маслёнка стеклянная с крышкой,Посуда,Ладья,68990,обычный +TECH-0005,Миксер ручной 500 Вт,Бытовая техника,Электрон,248990,обычный +TECH-0006,Измельчитель с чашей 1.5 л,Бытовая техника,Электрон,298990,обычный +TECH-0007,Стационарный блендер 1.8 л,Бытовая техника,Электрон,549000,магнит +TECH-0008,Мясорубка электрическая 1800 Вт,Бытовая техника,Электрон,799000,магнит +TECH-0009,Тостер на 2 ломтика 850 Вт,Бытовая техника,Электрон,299000,залёживается +TECH-0010,Вафельница электрическая 1000 Вт,Бытовая техника,Электрон,348999,обычный +TECH-0011,Мультиварка с чашей 5 л,Бытовая техника,Электрон,699000,магнит +TECH-0012,Электроплитка на 1 конфорку 1500 Вт,Бытовая техника,Электрон,249000,обычный +TECH-0013,Мини-печь с грилем 30 л,Бытовая техника,Электрон,899000,залёживается +TECH-0014,Микроволновая печь 20 л,Бытовая техника,Электрон,999000,обычный +TECH-0015,Сушилка для овощей 5 поддонов,Бытовая техника,Электрон,499000,обычный +TECH-0016,Кофемолка электрическая 200 Вт,Бытовая техника,Электрон,199000,обычный +TECH-0017,Капельная кофеварка 1.2 л,Бытовая техника,Электрон,399000,залёживается +TECH-0018,Соковыжималка шнековая 150 Вт,Бытовая техника,Электрон,799000,залёживается +TECH-0019,Утюг паровой 2400 Вт,Бытовая техника,Электрон,349000,обычный +TECH-0020,Швейная машина 12 операций,Бытовая техника,Электрон,1299000,обычный +TECH-0021,Пылесос контейнерный 2000 Вт,Бытовая техника,Электрон,1099000,залёживается +TECH-0022,Пылесос вертикальный аккумуляторный,Бытовая техника,Электрон,1499000,магнит +TECH-0023,Электровеник с контейнером 0.5 л,Бытовая техника,Электрон,699000,магнит +TECH-0024,Фен с диффузором 2200 Вт,Бытовая техника,Электрон,299000,обычный +TECH-0025,Щипцы для волос 45 Вт,Бытовая техника,Электрон,249000,обычный +TECH-0026,Машинка для стрижки с 6 насадками,Бытовая техника,Электрон,399000,залёживается +TECH-0027,Весы напольные до 180 кг,Бытовая техника,Электрон,198990,залёживается +TECH-0028,Весы кухонные до 5 кг,Бытовая техника,Электрон,148990,обычный +TECH-0029,Увлажнитель воздуха 3 л,Бытовая техника,Электрон,499000,обычный +TECH-0030,Тепловентилятор керамический 1500 Вт,Бытовая техника,Электрон,398990,магнит +KIDS-0004,Кроватка кукольная деревянная 45 см,Детские товары,Малышок,248999,обычный +KIDS-0005,Манеж складной 100×100,Детские товары,Малышок,798990,залёживается +KIDS-0006,Горшок детский со спинкой,Детские товары,Малышок,79000,магнит +KIDS-0007,Ванночка детская 84 см,Детские товары,Малышок,168990,обычный +KIDS-0008,Подставка детская двухступенчатая,Детские товары,Малышок,89000,залёживается +KIDS-0009,Накладка на унитаз с ручками,Детские товары,Малышок,69000,обычный +KIDS-0010,Сиденье для купания на присосках,Детские товары,Малышок,138990,залёживается +KIDS-0011,Пелёнки муслиновые 80×100 3 штуки,Детские товары,Малышок,129000,обычный +KIDS-0012,Одеяло для коляски 90×110,Детские товары,Малышок,148990,магнит +KIDS-0013,Нагрудники непромокаемые 5 штук,Детские товары,Малышок,58999,обычный +KIDS-0014,Бутылочка с ручками 250 мл,Детские товары,Малышок,49000,обычный +KIDS-0015,Поильник непроливайка 300 мл,Детские товары,Малышок,54990,залёживается +KIDS-0016,Контейнер для пустышки 2 штуки,Детские товары,Малышок,25000,магнит +KIDS-0017,Погремушки мягкие 3 штуки,Детские товары,Малышок,59000,магнит +KIDS-0018,Кубики развивающие 12 штук,Детские товары,Малышок,89000,залёживается +KIDS-0019,Пирамидка деревянная 8 колец,Детские товары,Малышок,78990,обычный +KIDS-0020,Сортер с геометрическими фигурами,Детские товары,Малышок,98990,обычный +KIDS-0021,Конструктор деревянный 50 деталей,Детские товары,Малышок,139000,обычный +KIDS-0022,Доска для рисования магнитная 30×40,Детские товары,Малышок,128990,обычный +KIDS-0023,Мольберт детский двусторонний 105 см,Детские товары,Малышок,298999,залёживается +KIDS-0024,Набор для лепки 12 цветов,Детские товары,Малышок,69000,обычный +KIDS-0025,Коврик игровой складной 180×150,Детские товары,Малышок,399000,обычный +KIDS-0026,Палатка игровая с окошками,Детские товары,Малышок,199000,обычный +KIDS-0027,Каталка с ручкой и звуком,Детские товары,Малышок,169000,залёживается +KIDS-0028,Самокат трёхколёсный до 50 кг,Детские товары,Малышок,298999,обычный +KIDS-0029,Санки со спинкой и ремнём,Детские товары,Малышок,349000,залёживается +KIDS-0030,Рюкзак детский объёмом 8 л,Детские товары,Малышок,128990,обычный +WEAR-0004,Футболка хлопковая размер 48,Одежда и обувь,Сатин,78990,залёживается +WEAR-0005,Майка хлопковая размер 46,Одежда и обувь,Сатин,58990,обычный +WEAR-0006,Лонгслив трикотажный размер 50,Одежда и обувь,Сатин,108990,магнит +WEAR-0007,Водолазка хлопковая размер 44,Одежда и обувь,Сатин,118990,обычный +WEAR-0008,Толстовка с капюшоном размер 52,Одежда и обувь,Сатин,198999,обычный +WEAR-0009,Брюки спортивные размер 50,Одежда и обувь,Сатин,169000,залёживается +WEAR-0010,Легинсы хлопковые размер 46,Одежда и обувь,Сатин,98990,залёживается +WEAR-0011,Пижама трикотажная размер 48,Одежда и обувь,Сатин,179000,обычный +WEAR-0012,Ночная сорочка хлопковая размер 50,Одежда и обувь,Сатин,128999,обычный +WEAR-0013,Фартук рабочий с тремя карманами,Одежда и обувь,Сатин,79000,залёживается +WEAR-0014,Перчатки хозяйственные 3 пары,Одежда и обувь,Ходики,39000,обычный +WEAR-0015,Варежки вязаные размер 8,Одежда и обувь,Ходики,68990,обычный +WEAR-0016,Шапка вязаная с отворотом размер 56,Одежда и обувь,Ходики,88990,магнит +WEAR-0017,Шарф шерстяной 30×180,Одежда и обувь,Ходики,119000,обычный +WEAR-0018,Ремень текстильный длиной 120 см,Одежда и обувь,Ходики,78990,обычный +WEAR-0019,Стельки войлочные 2 пары,Одежда и обувь,Ходики,38990,залёживается +WEAR-0020,Шнурки круглые 120 см 3 пары,Одежда и обувь,Ходики,25000,обычный +WEAR-0021,Кеды текстильные размер 42,Одежда и обувь,Ходики,179000,обычный +WEAR-0022,Кроссовки прогулочные размер 39,Одежда и обувь,Ходики,248990,обычный +WEAR-0023,Ботинки утеплённые размер 41,Одежда и обувь,Ходики,348990,залёживается +WEAR-0024,Сапоги резиновые размер 40,Одежда и обувь,Ходики,199000,обычный +WEAR-0025,Полуботинки кожаные размер 43,Одежда и обувь,Ходики,398999,залёживается +WEAR-0026,Сандалии с ремешками размер 38,Одежда и обувь,Ходики,189000,обычный +WEAR-0027,Сланцы резиновые размер 41,Одежда и обувь,Ходики,79000,обычный +WEAR-0028,Балетки текстильные размер 37,Одежда и обувь,Ходики,149000,залёживается +WEAR-0029,Туфли домашние утеплённые размер 39,Одежда и обувь,Ходики,129000,магнит +WEAR-0030,Галоши садовые размер 42,Одежда и обувь,Ходики,98990,обычный diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index 4ef87ef..cc0299b 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -223,10 +223,12 @@ smoke — сравнение `system.columns` поднятого стенда с ## 3. Каталог товаров CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`, -`brand`, `price`) — **словарь ClickHouse** из файла. Тот же файл использует -генератор — расхождений нет по построению. Даёт `dictGet` в витринах и -разговор о политике обновления словаря. На кластере файл монтируется в обе -ноды, словарь создаётся ON CLUSTER. +`brand`, `price`, `demand`) — **словарь ClickHouse** из файла. Тот же файл +использует генератор — расхождений нет по построению. Даёт `dictGet` в +витринах и разговор о политике обновления словаря. На кластере файл +монтируется в обе ноды, словарь создаётся ON CLUSTER. Последняя колонка — +уровень спроса товара, заведена при исполнении #50 (спека генератора, +раздел 9): генератор решает по ней, что уходит из карточки в корзину. ## 4. Сверка `purchase` против заказов diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 8e53cd8..5ac2dd3 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -754,14 +754,90 @@ pytest-тест с маркером `perf` и таймаутом-обрубан склонность из цены* — жёсткая связь, в которой менти найдёт ровно то, что мы в неё вложили, и ничего сверх. - **Границы правки — те же вилки раздела 5**: аудитория 6–8 тыс., средний - день около 50 тыс. событий (сейчас 49 509 — запас невелик, и рост - событий корзины считается против него), конверсия визита около 2% - (сейчас 2,43%). Заказ рисуется из корзины, а корзина сузилась, поэтому - доля брошенных позиций (число мира — 15%, замер по покупающим корзинам — - 12%) и состав заказа (1,87 позиции) перемеряются вместе с - остальным: сохраняется не число, а свойство — заказ уже корзины и - однопозиционных заказов около половины. Числа канонического мира после - правки перемеряются и переписываются. + день около 50 тыс. событий, конверсия визита около 2%. Заказ рисуется из + корзины, а корзина сузилась, поэтому доля брошенных позиций и состав + заказа перемеряются вместе с остальным: сохраняется не число, а + свойство — заказ уже корзины и однопозиционных заказов около половины. + +**Измерено после правки #50** (канонический мир, 14 дней; правый столбец — +то же до правки). Доли по уровням и состав заказа считаются по окну снимка +целиком, а не как среднее дневных долей: заказов в дне около 240, и на одном +дне такая доля гуляет на несколько пунктов от одной случайности выборки. +Трафиковая половина не сдвинулась ни на строку: набор просмотров страниц +сверен со снимком, снятым до первой правки. + +| величина | стало | было | +|---|---|---| +| дневная аудитория | 6 637–7 595, средняя 7 166 | без изменений | +| визитов в день | 9 879 | без изменений | +| событий в день, средних | 49 834 | 49 509 | +| просмотров карточек в день | 25 067 | без изменений | +| событий корзины в день | 2 161, в 1 306 визитах | 1 836, в 765 визитах | +| из них визиты без страницы `/cart` | 541 — 41,4% | 0 | +| визитов со страницей `/cart` без позиции | 0 | 0 | +| просмотров карточек, дошедших до корзины | 8,62% | 7,32% | +| конверсия карточки в корзину: магнит / обычный / залёживается | 13,1% / 9,2% / 5,8% | 7,7% у всех | +| событий корзины вне `/cart`: магнит / обычный / залёживается | 42,0% / 20,2% / 17,1% | 0 у всех | +| конверсия визита в покупку | 2,43% | без изменений | +| позиций на заказ | 1,74; однопозиционных 56,4% | 1,87; 50,3% | +| брошено позиций в покупающих корзинах | 7,25% | 12,0% | + +Знаменатель «просмотров карточек» здесь — только просмотры страниц. При +постановке #50 в него попали и строки событий корзины: событие садится на +карточку и несёт её же страницу, отчего вышло 26 903 просмотра и 6,82% +вместо 25 067 и 7,32%. Величина одна и та же, счёт разный. + +Решено при исполнении #50 самой реализацией: + +- **Намерение визита берётся из шага воронки, а не из метки покупателя.** + Кладёт всякий визит, открывший карточку, но дошедший до `/cart` кладёт + почти всё, что сравнивал, а прочий — редко. Метка покупателя в это + решение не заходит, и не потому, что не влияет: она уже влияет — через + воронку, где помеченному дано 18% против 6%. Взять её вторым слагаемым + значило бы посчитать одно и то же дважды и протянуть план состава сквозь + шов, по которому день отдаёт торговой половине только страницы и товары. + Плата названа: у визита без намерения добавление ни с чем в человеке не + связано — упрощение, но не ложь, потому что склонные покупать и так + преобладают среди дошедших до `/cart`. +- **Два ряда вероятностей, а не ряд с множителем.** Уровень спроса читается + дважды: ряд для визита с намерением и ряд для того, кто смотрит. Один ряд + с множителем намерения не годится: вероятности визита с намерением обязаны + быть высокими (иначе его корзина схлопывается до одной позиции, а заказ до + одного товара), а у потолка в 100% разница уровней съедается, и конверсия + по уровням перестаёт различаться. Разрыв уровней внутри рядов поэтому + разный: у смотрящего товар решает почти всё, у пришедшего покупать — + заслонён намерением. +- **Доля брошенных позиций опущена с 15% до 10%.** Корзина сузилась с 2,4 + позиции до 1,7, и прежние 15% чаще выносили её целиком: спасённая позиция + возвращала заказ к одному товару, и однопозиционных заказов набиралось под + 60%. Свойство сохранено, число перемерено — ровно так, как этот блок и + оговаривал. +- **Ни одному уровню спроса нельзя дать нулевую вероятность.** Ноль у + залежавшегося товара выглядел заманчиво — он дешевле всего разводил + конверсию уровней, — но по товарам этого уровня возвращал ровно то + равенство, ради которого правка и делалась: событие корзины случалось бы + только внутри визита, открывшего `/cart`, без единого исключения на + четверти каталога. Отвязка либо про весь ассортимент, либо она не отвязка. + Разводить уровни поэтому приходится не нулём внизу второго ряда, а + ослаблением залежавшегося в первом: пришедший покупать берёт его реже + ходового. +- **Уровень спроса — колонка `demand` в конце файла каталога**, значения + словами («магнит», «обычный», «залёживается»). Раздан он одной колодой на + весь каталог: доли по категориям расходятся сами (магнитов от четырёх до + девяти на тридцать строк). Раскладывать колоду поровну в каждую категорию + было бы той же неправдоподобной ровностью, какую тикет вычищал из данных. + Связь с ценой отклонена выше, и тест сторожит её отсутствие — по четвертям + цены и по медиане, потому что одни только края списка обманываются + четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом + ещё не построен (#37/#43) — колонку надо взять в его описание сразу, а не + догонять правкой; манифесту нужен хеш каталога (хвост #42) тем более, + потому что теперь в файле живёт ещё и поведение. +- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6% + и подошла к потолку вилки.** Иначе не сошлось: треть кладущих визитов вне + `/cart` требует своих позиций, а состав заказа требует, чтобы визит с + намерением клал почти всё сравненное. Обе границы тянут число вверх, и + опустить его — значит отпустить одну из них. Место до 9% невелико, и + следующая правка торговой половины считается против него. Остаётся открытым, за тикетами: diff --git a/generator/src/clickstream_generator/catalog.py b/generator/src/clickstream_generator/catalog.py index f54d020..bf84c83 100644 --- a/generator/src/clickstream_generator/catalog.py +++ b/generator/src/clickstream_generator/catalog.py @@ -10,16 +10,22 @@ раз в неделю, корзина в двадцать позиций. Что решено формой файла, а не его длиной: колонки `sku,name,category,brand, -price`; артикул — четыре латинские буквы категории, дефис и четыре цифры; -цена — целые копейки (деньги генератор считает целыми, спека, раздел 2). -Часть цен кратна рублю, часть несёт копейки — как в обычной рознице -(1 289,90 ₽). Без копеек урок про Float64 был бы беспредметным: округлять -нечего, и разрыв между `productPrice` и `purchaseRevenue` пришлось бы -выдумывать (спека генератора, раздел 9). +price,demand`; артикул — четыре латинские буквы категории, дефис и четыре +цифры; цена — целые копейки (деньги генератор считает целыми, спека, +раздел 2). Часть цен кратна рублю, часть несёт копейки — как в обычной +рознице (1 289,90 ₽). Без копеек урок про Float64 был бы беспредметным: +округлять нечего, и разрыв между `productPrice` и `purchaseRevenue` +пришлось бы выдумывать (спека генератора, раздел 9). Строк в файле может быть сколько угодно: ни генератор, ни тесты их не считают, а товар для карточки выбирается равномерно внутри категории. -Популярность товаров не моделируется — придумывать вес каждой строке -пришлось бы вручную, а каталог растёт механически. + +**Уровень спроса** — привлекательность товара, а не частота его показа: +карточку любого товара открывают одинаково часто, но магнит из открытой +карточки уходит в корзину чаще, чем залежавшийся. Уровень приписан строке +в файле — он постоянная часть мира, а не бросок дня, — и приписан россыпью: +ни с ценой, ни с категорией он не связан. Связь с ценой была бы жёсткой, и +менти нашёл бы в данных ровно то, что мы в них вложили (спека генератора, +раздел 9). Сами вероятности живут в числах мира, здесь только имена уровней. """ import csv @@ -34,7 +40,11 @@ from numpy.typing import NDArray # Каталог не настраивается извне — он часть мира, а не запуска. CATALOG_PATH = Path(__file__).resolve().parents[3] / "data" / "catalog" / "products.csv" -COLUMNS = ("sku", "name", "category", "brand", "price") +COLUMNS = ("sku", "name", "category", "brand", "price", "demand") + +# Уровни спроса по убыванию желанности: порядок здесь — порядок чисел мира, +# которыми уровень превращается в вероятность (`world.SHOPPING_ADD_PERCENT`). +DEMAND_LEVELS = ("магнит", "обычный", "залёживается") @dataclass(frozen=True, slots=True) @@ -70,6 +80,7 @@ class Catalog: category: NDArray[np.int64] brand: NDArray[np.object_] price: NDArray[np.int64] + demand: NDArray[np.int64] grouped: NDArray[np.int64] first: NDArray[np.int64] count: NDArray[np.int64] @@ -85,12 +96,14 @@ def catalog() -> Catalog: category = np.array([index[row["category"]] for row in rows], dtype=np.int64) grouped = np.argsort(category, kind="stable") count = np.bincount(category, minlength=len(CATEGORIES)) + level = {name: number for number, name in enumerate(DEMAND_LEVELS)} return Catalog( sku=np.array([row["sku"] for row in rows], dtype=object), name=np.array([row["name"] for row in rows], dtype=object), category=category, brand=np.array([row["brand"] for row in rows], dtype=object), price=np.array([int(row["price"]) for row in rows], dtype=np.int64), + demand=np.array([level[row["demand"]] for row in rows], dtype=np.int64), grouped=grouped, first=np.concatenate(([0], np.cumsum(count)[:-1])), count=count, diff --git a/generator/src/clickstream_generator/commerce.py b/generator/src/clickstream_generator/commerce.py index b773d90..e6841dc 100644 --- a/generator/src/clickstream_generator/commerce.py +++ b/generator/src/clickstream_generator/commerce.py @@ -8,12 +8,18 @@ граница модельных суток режет всё, что за неё вышло: визит, у которого подтверждение срезано полуночью, покупки не даёт — заказа не было. -**Корзина шире заказа.** Посетитель кладёт товары тех карточек, которые -открывал в этом визите; карточка, открытая дважды, даёт одну позицию — -повторный просмотр это раздумье, а не второй товар. Покупает он не всё: -часть позиций остаётся брошенной. Иначе событие корзины не рассказывало бы -ничего сверх покупки — заказ был бы её точной копией, и сравнивать было бы -нечего. +**Кладёт любой визит, открывший карточку.** Положить и корзину не открыть — +самый массовый сюжет магазина, поэтому событие корзины не привязано к +странице `/cart`: иначе факт добавления идеально предсказывался бы более +поздним просмотром страницы, а такого равенства в живых данных не бывает. +Решается каждая открытая карточка отдельно, а не визит целиком, и решают её +двое: намерение визита и уровень спроса товара (числа мира). Карточка, +открытая дважды, даёт одну позицию — повторный просмотр это раздумье, а не +второй товар. + +**Корзина шире заказа.** Покупает посетитель не всё, что положил: часть +позиций остаётся брошенной. Иначе событие корзины не рассказывало бы ничего +сверх покупки — заказ был бы её точной копией, и сравнивать было бы нечего. **Деньги считаются целыми копейками.** В колонку `productPrice` ложатся целые рубли, как у Метрики, а дробное число в событии одно — @@ -145,7 +151,7 @@ def weave( события встают между ними, и поток пересобирается одним порядком. """ rng = day_stream(seed, day, Component.COMMERCE) - baskets = _baskets(page, product, columns["VisitID"]) + baskets = _baskets(rng, page, product, columns["VisitID"]) if not len(baskets): return columns, page, product @@ -172,13 +178,24 @@ def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws: def _baskets( - page: NDArray[np.uint8], product: NDArray[np.int64], visit: NDArray[np.uint64] + rng: np.random.Generator, + page: NDArray[np.uint8], + product: NDArray[np.int64], + visit: NDArray[np.uint64], ) -> _Baskets: """Что посетитель положил в корзину и дошёл ли до заказа. - Корзина есть у визита, дошедшего до страницы корзины; в ней товары всех - карточек этого визита. Карточка, открытая дважды, даёт одну позицию — - остаётся первая: тогда товар и кладут. + Корзина есть у всякого визита, положившего хоть что-то, — не только у + дошедшего до страницы `/cart`. Кандидаты — карточки, открытые в визите, + по одной на товар: карточка, открытая дважды, даёт одну позицию, и + остаётся первая — тогда товар и кладут. + + Намерение визита берётся из шага воронки: тот, кто дошёл до страницы + корзины, пришёл покупать. Позиция у него есть всегда — страница корзины + при пустой корзине невозможна, — и держится это высокой вероятностью, а + не принуждением: спасать приходится считаные проценты таких визитов. + Принуждай мы всех, корзина схлопнулась бы до одной позиции, а заказ до + одного товара. """ order = np.argsort(visit, kind="stable") page, product, visit = page[order], product[order], visit[order] @@ -187,54 +204,103 @@ def _baskets( # упорядоченным, а сортировка по визиту устойчивая. started = np.concatenate(([True], visit[1:] != visit[:-1])) number = np.cumsum(started) - 1 + visits = int(started.sum()) - with_cart = number[page == Page.CART] - basket_of_visit = np.full(int(started.sum()), -1, dtype=np.int64) - basket_of_visit[with_cart] = np.arange(with_cart.size) - - cards = np.flatnonzero((page == Page.PRODUCT) & (basket_of_visit[number] >= 0)) + goods = catalog.catalog() + cards = np.flatnonzero(page == Page.PRODUCT) # Ключ «визит и товар»: `np.unique` отдаёт индексы первых вхождений, - # поэтому вторая карточка того же товара позиции не добавляет. - key = number[cards] * catalog.catalog().sku.size + product[cards] - positions = np.sort(cards[np.unique(key, return_index=True)[1]]) + # поэтому вторая карточка того же товара кандидата не добавляет. + key = number[cards] * goods.sku.size + product[cards] + opened = np.sort(cards[np.unique(key, return_index=True)[1]]) + holder = number[opened] - # Подтверждение без корзины невозможно: полночь режет визит с хвоста, а - # корзина в нём раньше подтверждения — поэтому у каждого подтверждения - # корзина есть, и номер её всегда найдётся. + shopping = np.zeros(visits, dtype=np.bool_) + shopping[number[page == Page.CART]] = True + taken = _taken(rng, goods.demand[product[opened]], shopping[holder]) + # Кандидаты визита лежат подряд, визиты идут по порядку — поэтому начало + # каждого куска находится накопленной суммой. + opened_count = np.bincount(holder, minlength=visits) + opened_first = np.cumsum(opened_count) - opened_count + empty = shopping & (np.bincount(holder[taken], minlength=visits) == 0) + _at_least_one(rng, taken, opened_first, opened_count, empty) + + positions, owner = opened[taken], holder[taken] + with_basket = np.unique(owner) + basket_of_visit = np.full(visits, -1, dtype=np.int64) + basket_of_visit[with_basket] = np.arange(with_basket.size) + + # Подтверждение без корзины невозможно: до него визит прошёл страницу + # корзины, а у неё позиция есть всегда — поэтому номер корзины найдётся. confirmed = np.flatnonzero(page == Page.CONFIRMATION) - confirmation = np.full(with_cart.size, -1, dtype=np.int64) + confirmation = np.full(with_basket.size, -1, dtype=np.int64) confirmation[basket_of_visit[number[confirmed]]] = order[confirmed] - basket = basket_of_visit[number[positions]] - count = np.bincount(basket, minlength=with_cart.size) + basket = basket_of_visit[owner] + count = np.bincount(basket, minlength=with_basket.size) return _Baskets( anchor=order[positions], product=product[positions], basket=basket, - # Позиции лежат подряд, корзины идут по порядку визитов — поэтому - # начало каждого куска находится накопленной суммой. + # Позиции лежат подряд, корзины идут по порядку визитов — начало + # каждого куска находится той же накопленной суммой. first=np.cumsum(count) - count, count=count, confirmation=confirmation, ) +def _taken( + rng: np.random.Generator, + demand: NDArray[np.int64], + shopping: NDArray[np.bool_], +) -> NDArray[np.bool_]: + """Дошла ли открытая карточка до корзины: решают намерение и товар. + + Два ряда чисел мира по уровням спроса: один для визита, пришедшего + покупать, другой для того, кто просто смотрит. Товар решает у обоих, но + у смотрящего он решает почти всё — потому конверсия «карточка → корзина» + по уровням спроса и становится различимой. + """ + chance = np.where( + shopping, + np.array(world.SHOPPING_ADD_PERCENT)[demand], + np.array(world.BROWSING_ADD_PERCENT)[demand], + ) + return rng.integers(0, 100, demand.size) < chance + + def _kept(rng: np.random.Generator, baskets: _Baskets) -> NDArray[np.bool_]: """Какие позиции корзины дошли до заказа: часть остаётся брошенной. - Пустым заказ не бывает: если брошены все позиции, одна возвращается — - какая, решает свой бросок. Хотя бы одна позиция у корзины есть всегда: - перед корзиной визит обязательно открывал карточку. + Пустым заказ не бывает: если брошены все позиции, одна возвращается. """ kept = ( rng.integers(0, 100, baskets.product.size) >= world.ABANDONED_POSITION_PERCENT ) - rescued = baskets.first + rng.integers(0, baskets.count) empty = np.bincount(baskets.basket[kept], minlength=len(baskets)) == 0 - kept[rescued[empty]] = True + _at_least_one(rng, kept, baskets.first, baskets.count, empty) return kept +def _at_least_one( + rng: np.random.Generator, + chosen: NDArray[np.bool_], + first: NDArray[np.int64], + count: NDArray[np.int64], + empty: NDArray[np.bool_], +) -> None: + """Возвращает один выбор тем группам, у которых бросок унёс все. + + Приём общий у двух мест: у визита выбираются карточки, у корзины — + позиции, а правило одно — пустой ни та, ни другая быть не может. Какой + выбор вернуть, решает свой бросок: брать первый значило бы, что ранняя + карточка визита переживает любой бросок. Группа без членов сюда не + приходит: у визита со страницей корзины карточка была, у корзины — + позиция. + """ + chosen[first[empty] + rng.integers(0, count[empty])] = True + + def _coupons(rng: np.random.Generator, baskets: int) -> NDArray[np.int64]: """Промокод корзины: номер строки в таблице кодов или −1, если кода нет.""" code = rng.integers(0, len(world.COUPONS), baskets) diff --git a/generator/src/clickstream_generator/day.py b/generator/src/clickstream_generator/day.py index 384d3ba..59df10f 100644 --- a/generator/src/clickstream_generator/day.py +++ b/generator/src/clickstream_generator/day.py @@ -258,8 +258,9 @@ def _walk( goods = catalog.catalog() shown = page == Page.PRODUCT row_category = np.repeat(visits.category, visits.pages)[shown] - # Товар — равномерно внутри категории визита: популярность строк не - # моделируется, каталог дорастает механически (см. `catalog`). + # Товар — равномерно внутри категории визита: карточки всех товаров + # открывают одинаково часто, а различает их уровень спроса — уже в + # корзине, а не в показе (см. `catalog`). inside = rng.integers(0, goods.count[row_category]) product = np.full(total, -1, dtype=np.int64) product[shown] = goods.grouped[goods.first[row_category] + inside] diff --git a/generator/src/clickstream_generator/world.py b/generator/src/clickstream_generator/world.py index 5b0e995..1ade6fb 100644 --- a/generator/src/clickstream_generator/world.py +++ b/generator/src/clickstream_generator/world.py @@ -160,11 +160,40 @@ BUYER_CHECKOUT_OF_CART_PERCENT = 60 # страницу, на которой посетитель его нажал. TRADE_DELAY_SECONDS = (2, 8) +# Кто и что кладёт в корзину: доля открытых карточек, которая до неё дошла. +# Оба ряда читаются по уровням спроса товара (`catalog.DEMAND_LEVELS`): +# магнит, обычный, залёживается. +# +# Первый ряд — визит, дошедший до страницы корзины. Он пришёл покупать и +# кладёт почти всё, что сравнивал; уровень решает, что именно из сравненного +# он выберет, и залежавшийся товар проигрывает соседям по вкладке. Верх ряда +# высок намеренно: опусти его — и корзина такого визита схлопнется до одной +# позиции, а заказ до одного товара. +# +# Второй ряд — визит, который до страницы корзины не дошёл. Кладёт он редко, +# и магнит уходит у него из карточки втрое чаще обычного товара и вшестеро +# чаще залежавшегося: случайного посетителя решает соблазнить сам товар, +# больше решать нечему. Событий при этом выходит почти поровну — магнитов +# 47% таких добавлений, обычных 42%: обычных в каталоге вдвое с лишним +# больше, и разница уровней живёт в доле открытых карточек, а не в счёте +# событий. Нулю в этом ряду места нет: обнулить уровень значило бы вернуть +# по его товарам ровно то равенство «положил — открыл корзину», ради +# которого правка и делалась. +# +# Спрос растянут во втором ряду сильнее, чем в первом, и это не подгонка: +# намерение заслоняет товар, поэтому привлекательность видна там, где +# намерения нет. +SHOPPING_ADD_PERCENT = (98, 94, 55) +BROWSING_ADD_PERCENT = (6, 2, 1) + # Доля позиций корзины, которые остались брошенными: заказ уже корзины. # Иначе событие корзины не рассказывало бы ничего сверх покупки — заказ был # бы её точной копией, и сравнивать было бы нечего. Пустым заказ не бывает: # если брошены все позиции, одна остаётся (спека генератора, раздел 9). -ABANDONED_POSITION_PERCENT = 15 +# Число опущено с 15% вместе с отвязкой корзины от страницы корзины: корзина +# стала уже (1,7 позиции вместо 2,4), и прежние 15% чаще выносили её целиком, +# а спасённая позиция возвращала заказ к одному товару. +ABANDONED_POSITION_PERCENT = 10 # Сколько штук одного товара берут: веса для 1, 2, 3 штук. Обычно одна, # изредка две-три — непродовольственная розница. diff --git a/generator/tests/test_catalog.py b/generator/tests/test_catalog.py index fb20d9e..b7c17b6 100644 --- a/generator/tests/test_catalog.py +++ b/generator/tests/test_catalog.py @@ -6,8 +6,10 @@ целая цена. """ +import collections import csv import re +import statistics import numpy as np @@ -78,6 +80,96 @@ def test_categories_are_told_apart_by_prefix_and_by_address(): assert len(prefixes) == len(slugs) == len(catalog.CATEGORIES) +def test_every_row_carries_a_known_demand_level(): + """Уровень спроса есть у каждой строки, и уровней ровно три.""" + known = set(catalog.DEMAND_LEVELS) + by_category: dict[str, set[str]] = {} + for row in rows(): + by_category.setdefault(row["category"], set()).add(row["demand"]) + assert set().union(*by_category.values()) == known + # Уровни рассыпаны по всему ассортименту: категории целиком из магнитов + # или целиком из залежавшегося в магазине не бывает. + for category, here in by_category.items(): + assert here == known, category + + +def test_the_demand_is_scattered_and_not_dealt_out_evenly(): + """Одинаковый расклад в каждой категории — та же неправдоподобная ровность. + + Ровно её тикет и вычищал из данных: в живом магазине магнитов в посуде и + в одежде не поровну. Уровень тянется из одной колоды на весь каталог, + поэтому доли категорий расходятся сами. + """ + magnets: dict[str, int] = {} + for row in rows(): + magnets.setdefault(row["category"], 0) + magnets[row["category"]] += row["demand"] == catalog.DEMAND_LEVELS[0] + assert len(set(magnets.values())) > 1, magnets + + +def price_tells_the_level(catalogue: list[dict[str, str]]) -> bool: + """Читается ли уровень спроса по цене товара. + + Спрашивается двумя способами сразу, потому что каждый по отдельности + обманывается: по четвертям цены — все ли три уровня встречаются в каждой, + и по медиане цены уровня — не съехала ли она от медианы каталога. Одни + только края списка ничего не стерегут: четырёх исключений в самых + дешёвых и самых дорогих строках хватает, чтобы вернуть в них все уровни. + """ + known = set(catalog.DEMAND_LEVELS) + by_price = sorted(catalogue, key=lambda row: int(row["price"])) + step = len(by_price) // 4 + quarters = [by_price[start : start + step] for start in range(0, 4 * step, step)] + if any({row["demand"] for row in part} != known for part in quarters): + return True + + whole = statistics.median(int(row["price"]) for row in catalogue) + for level in catalog.DEMAND_LEVELS: + here = [int(row["price"]) for row in catalogue if row["demand"] == level] + if not 0.7 < statistics.median(here) / whole < 1.3: + return True + return False + + +def derived_from_price(catalogue: list[dict[str, str]]) -> list[dict[str, str]]: + """Подложный каталог: уровень выведен из цены, по краям — исключения. + + Исключения подобраны так, чтобы в самой дешёвой и самой дорогой четверти + нашлись все три уровня: столько и нужно, чтобы обмануть проверку, которая + смотрит только на края списка. + """ + magnet, usual, slow = catalog.DEMAND_LEVELS + by_price = sorted(catalogue, key=lambda row: int(row["price"])) + counted = collections.Counter(row["demand"] for row in catalogue) + deck = [level for level in catalog.DEMAND_LEVELS for _ in range(counted[level])] + forged = [ + dict(row, demand=level) for row, level in zip(by_price, deck, strict=True) + ] + forged[0]["demand"] = slow + forged[-1]["demand"] = magnet + forged[-2]["demand"] = usual + return forged + + +def test_the_demand_level_is_not_the_price_in_disguise(): + """Отклонённый вариант: выводить склонность из цены (спека, раздел 9). + + Жёсткая связь дала бы менти ровно то, что мы в неё вложили: «дорогое + залёживается» читалось бы из каталога, а не из данных. + """ + assert not price_tells_the_level(rows()) + # Сторож сторожа: подмени уровни ценой — проверка обязана покраснеть, + # и краевые исключения её не должны обманывать. + assert price_tells_the_level(derived_from_price(rows())) + + +def test_the_demand_level_reaches_the_arrays_row_by_row(): + goods = catalog.catalog() + assert [catalog.DEMAND_LEVELS[number] for number in goods.demand.tolist()] == [ + row["demand"] for row in rows() + ] + + def test_the_catalog_is_grouped_by_category_whatever_the_file_order(): goods = catalog.catalog() assert goods.count.sum() == goods.sku.size diff --git a/generator/tests/test_commerce.py b/generator/tests/test_commerce.py index a135f12..0a2f6fb 100644 --- a/generator/tests/test_commerce.py +++ b/generator/tests/test_commerce.py @@ -21,6 +21,9 @@ from clickstream_generator.seeds import CANONICAL_SEED, Component WEEKDAY = 2 WEEK = 7 +# Окно эталонного снимка: доли по уровням спроса меряются на нём целиком — +# на одном дне у самого редкого уровня набирается слишком мало карточек. +FORTNIGHT = 14 @pytest.fixture(autouse=True) @@ -40,6 +43,13 @@ def week() -> list[day.Day]: return [day.stream(CANONICAL_SEED, number) for number in range(WEEK)] +@pytest.fixture(scope="module") +def fortnight(week: list[day.Day]) -> list[day.Day]: + """Две недели: неделя пересчитанной не бывает — день чист от соседей.""" + later = range(WEEK, FORTNIGHT) + return week + [day.stream(CANONICAL_SEED, number) for number in later] + + def rows_of(events: day.Day, kind: str) -> dict[str, NDArray[Any]]: """Строки одного типа события — все колонки разом.""" here = events.columns["EventType"] == kind @@ -69,11 +79,58 @@ def test_the_day_carries_both_trade_events(weekday: day.Day): assert carts["WatchID"].size > 2 * orders["WatchID"].size +def visits_with(events: day.Day, page: int) -> set[int]: + """Визиты, у которых эта страница магазина дожила до потока. + + Считаются просмотры страниц, а не строки с такой же страницей: торговое + событие садится на свою страницу и несёт её же, поэтому маска по одному + `page` посчитала бы событие корзины за просмотр карточки. + """ + here = (events.columns["EventType"] == "pageview") & (events.page == page) + return set(events.columns["VisitID"][here].tolist()) + + def confirmed_visits(events: day.Day) -> set[int]: """Визиты, чья страница подтверждения дожила до потока.""" + return visits_with(events, Page.CONFIRMATION) + + +def visits_that_put(events: day.Day) -> set[int]: + """Визиты с событием корзины — по типу события, а не по массивам товаров. + + Строка покупки тоже несёт товары, поэтому «строка с `productID`» — это + не «событие корзины». + """ + return set(rows_of(events, commerce.ADD_TO_CART)["VisitID"].tolist()) + + +def cart_products(events: day.Day) -> list[int]: + """Номера товаров, положенных в корзину, — по позиции на строку.""" + here = events.columns["EventType"] == commerce.ADD_TO_CART + return events.product[here].tolist() + + +def card_views(events: day.Day) -> int: + """Просмотры карточек товара в дне — строками потока, с повторами.""" pageview = events.columns["EventType"] == "pageview" - here = pageview & (events.page == Page.CONFIRMATION) - return set(events.columns["VisitID"][here].tolist()) + return int((pageview & (events.page == Page.PRODUCT)).sum()) + + +def opened_cards(events: day.Day) -> set[tuple[int, int]]: + """Разные карточки визитов: повторный просмотр внутри визита — один раз. + + Так же считается и позиция корзины, поэтому это и есть честный + знаменатель конверсии «просмотр карточки → корзина». + """ + pageview = events.columns["EventType"] == "pageview" + here = pageview & (events.page == Page.PRODUCT) + return set( + zip( + events.columns["VisitID"][here].tolist(), + events.product[here].tolist(), + strict=True, + ) + ) def test_a_confirmation_in_the_stream_always_has_its_purchase(weekday: day.Day): @@ -238,26 +295,63 @@ def test_the_rounded_price_does_not_add_up_to_the_revenue(weekday: day.Day): assert 0.1 < apart / orders["WatchID"].size < 0.9 -def test_the_order_is_narrower_than_the_cart(weekday: day.Day): - """Часть положенных позиций не куплена — иначе сравнивать было бы нечего.""" - carts = rows_of(weekday, commerce.ADD_TO_CART) - orders = rows_of(weekday, commerce.PURCHASE) +def basket_and_order(events: day.Day) -> tuple[list[int], list[int]]: + """По каждому заказу — сколько позиций было в корзине и сколько куплено. + + Заодно проверяется само отношение «заказ из корзины»: пустых заказов не + бывает, купленного мимо корзины не бывает, позиция в заказе одна на товар. + """ + carts = rows_of(events, commerce.ADD_TO_CART) + orders = rows_of(events, commerce.PURCHASE) put: dict[int, set[str]] = {} for visit, article in zip( carts["VisitID"].tolist(), (cell[0] for cell in carts["productID"]), strict=True ): put.setdefault(visit, set()).add(article) - dropped = whole = 0 + baskets: list[int] = [] + sizes: list[int] = [] for visit, bought in zip( orders["VisitID"].tolist(), cells(orders["productID"]), strict=True ): assert bought, "пустых заказов не бывает" assert set(bought) <= put[visit], "куплено то, чего не клали в корзину" assert len(set(bought)) == len(bought), "позиция в заказе одна на товар" - dropped += len(put[visit]) - len(bought) - whole += len(put[visit]) - assert 0.05 < dropped / whole < 0.30 + baskets.append(len(put[visit])) + sizes.append(len(bought)) + return baskets, sizes + + +def test_the_order_is_narrower_than_the_cart(weekday: day.Day): + """Часть положенных позиций не куплена — иначе сравнивать было бы нечего.""" + baskets, sizes = basket_and_order(weekday) + assert sum(baskets) > sum(sizes) + + +def test_the_order_keeps_its_shape_across_the_snapshot(fortnight: list[day.Day]): + """Состав заказа — доли окна снимка, а не одного дня. + + Заказов в дне около 240, и доля однопозиционных гуляет по дням на + несколько пунктов от одной случайности выборки; вилки тикета заданы на + 14 днях, там же они и меряются. Список позиций — единственный носитель + урока про вложенный JSON: схлопнись он в один товар, урок бы умер. + """ + baskets: list[int] = [] + sizes: list[int] = [] + for events in fortnight: + was, bought = basket_and_order(events) + baskets += was + sizes += bought + + assert 1.6 < sum(sizes) / len(sizes) < 2.2 + assert 0.40 < sum(size == 1 for size in sizes) / len(sizes) < 0.60 + # Брошено не ничего и не всё. Вилка та же, что и до правки: число мира + # опустилось с 15% до 10%, замер по покупающим корзинам — с 12,0% до + # 7,2%, и запас до нижней границы остался в полтора раза. Ниже её + # ронять незачем: с ней сторож замечает, что число мира срезали вдвое, + # а без неё — уже нет. + dropped = 1 - sum(sizes) / sum(baskets) + assert 0.05 < dropped < 0.30 def test_the_cart_holds_only_what_the_visitor_opened(weekday: day.Day): @@ -284,6 +378,102 @@ def test_the_cart_holds_only_what_the_visitor_opened(weekday: day.Day): assert len(set(products)) == len(products) +def test_putting_something_in_the_cart_is_not_opening_the_cart_page( + fortnight: list[day.Day], +): + """Положил и корзину не открыл — самый массовый сюжет магазина. + + До правки два множества совпадали в точности все 14 дней, и факт + добавления идеально предсказывался более поздним просмотром страницы — + такого равенства в живых данных не бывает. Сторож меряет долю, а не факт + различия одним визитом: одно расхождение прошлую ложь не лечит. + """ + quiet = putting = 0 + for events in fortnight: + put = visits_that_put(events) + quiet += len(put - visits_with(events, Page.CART)) + putting += len(put) + assert quiet / putting > 1 / 3 + + +def test_a_visit_that_opened_the_cart_page_has_something_in_the_cart( + fortnight: list[day.Day], +): + """Страница корзины при пустой корзине невозможна. + + Это включение намеренное и остаётся: ломалось обратное. Проверяется на + обеих неделях — и на буднях, и на выходных: правило про всякий визит, а + не про удачный день. + """ + for events in fortnight: + assert visits_with(events, Page.CART) <= visits_that_put(events) + + +def test_no_demand_level_is_locked_behind_the_cart_page(fortnight: list[day.Day]): + """Отвязка от страницы корзины — про весь ассортимент, а не про часть. + + Обнули вероятность целому уровню — и по его товарам вернётся ровно то + равенство, ради которого правка делалась: «положили — значит, откроют + корзину», без единого исключения на четверти каталога. Сторож меряет + долю у каждого уровня, а не факт: одно событие такую примету не лечит. + """ + goods = catalog.catalog() + levels = range(len(catalog.DEMAND_LEVELS)) + quiet = [0] * len(catalog.DEMAND_LEVELS) + put = [0] * len(catalog.DEMAND_LEVELS) + for events in fortnight: + shopping = visits_with(events, Page.CART) + here = events.columns["EventType"] == commerce.ADD_TO_CART + for visit, number in zip( + events.columns["VisitID"][here].tolist(), + events.product[here].tolist(), + strict=True, + ): + put[goods.demand[number]] += 1 + quiet[goods.demand[number]] += visit not in shopping + + for level in levels: + assert quiet[level] / put[level] > 0.10, catalog.DEMAND_LEVELS[level] + + +def test_the_demand_level_tells_the_cart_conversion_apart(fortnight: list[day.Day]): + """Привлекательность товара стала измеримой величиной, а не шумом. + + Знаменатель — разные карточки товаров этого уровня: повторный просмотр + внутри визита считается один раз, так же как считается позиция. До + правки решение принималось один раз на визит, и по sku выходил чистый + шум: внутри воронки 100%, вне её 0%. + """ + goods = catalog.catalog() + levels = range(len(catalog.DEMAND_LEVELS)) + shown = [0] * len(catalog.DEMAND_LEVELS) + put = [0] * len(catalog.DEMAND_LEVELS) + for events in fortnight: + for _, number in opened_cards(events): + shown[goods.demand[number]] += 1 + for number in cart_products(events): + put[goods.demand[number]] += 1 + + share = [put[level] / shown[level] for level in levels] + magnet, usual, slow = share + assert magnet > usual > slow + assert magnet >= 2 * slow + + +def test_the_cart_events_stay_inside_the_event_budget(fortnight: list[day.Day]): + """Меняется не сколько кладут, а кто и что: бюджет событий на месте. + + Доля просмотров карточек, дошедших до корзины, — та же величина, что + держала бюджет до правки; средний день остаётся около 50 тыс. событий + (спека генератора, разделы 5 и 9). + """ + put = sum(len(cart_products(events)) for events in fortnight) + seen = sum(card_views(events) for events in fortnight) + assert 0.05 < put / seen < 0.09 + average = sum(len(events) for events in fortnight) / len(fortnight) + assert 48_000 < average < 52_000 + + def test_a_trade_event_sits_on_the_page_that_sent_it(weekday: day.Day): """Корзина — на карточке того самого товара, покупка — на подтверждении. diff --git a/generator/tests/test_world.py b/generator/tests/test_world.py index 5724f07..872b16f 100644 --- a/generator/tests/test_world.py +++ b/generator/tests/test_world.py @@ -6,7 +6,7 @@ «в среднем 3–4 возврата» и «средняя кука активна ≈1,9 дня». """ -from clickstream_generator import world +from clickstream_generator import catalog, world def mean_by_weights(values: tuple[int, ...], weights: tuple[int, ...]) -> float: @@ -150,6 +150,23 @@ def test_the_buyer_mark_shows_on_both_steps_of_the_funnel(): assert 2 <= lift <= 6 +def test_the_demand_levels_keep_their_order_and_yield_to_the_intent(): + """Ряды вероятностей читаются по уровням каталога, и оба убывают. + + Совпадение длин — не формальность: ряды индексируются номером уровня, + и новый уровень в файле каталога обязан получить здесь своё число. + """ + rows = (world.SHOPPING_ADD_PERCENT, world.BROWSING_ADD_PERCENT) + for row in rows: + assert len(row) == len(catalog.DEMAND_LEVELS) + assert row[0] > row[1] > row[2], row + # Намерение сильнее товара: визит, пришедший покупать, кладёт чаще на + # любом уровне — иначе слово «намерение» ничего бы не значило. + assert all(shopping > browsing for shopping, browsing in zip(*rows, strict=True)) + # Но и не всё подряд: товар решает у обоих, спрос не декорация. + assert min(world.SHOPPING_ADD_PERCENT) < 100 + + def test_the_buyer_mark_makes_the_cookie_live_longer(): """Второй рычаг метки: без долгой жизни второй покупке негде случиться.""" assert world.BUYER_ONE_SHOT_PERCENT < world.ONE_SHOT_PERCENT