Внимание!

Прочитав статью, может сложиться впечатление, что я люблю БДСМ или что-то такое, но это вам только кажется.

Проблемы в работе магазина

Я работаю в обычном велосипедном магазине в центре Варшавы. Торгуем как стационарно, так и в интернете. Среднее количество купленных велосипедов за день ~2 на весь год. При этом пик продаж приходится на лето и тогда в день можем иметь по ~17 интернет-заказов и столько же в магазине, а зимой не продавать вообще ничего.

В 2020г. в связи с пандемией COVID, спрос на велосипеды вырос до невероятных показателей, а мы, как порядочная контора, начали расширение.

Это привело к тому, что в конце прошлого сезона, заказы только несуществующих велосипедов участились в среднем до 4-х раз в неделю на протяжении 4-х самых продуктивных месяцев. А это ~16 несоответствий на сайте в месяц (не считая фейлы в магазине).

Анулированные заказы
Анулированные заказы

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

В конце концов, чтобы как-нибудь унять этот хаос мне было поручено создать EXСEL, куда впишутся все велосипеды со складов, чтобы примерно знать их расположение. Конечно же, я наплевал на таблицу. Хотя бы потому что при нынешней текучке продуктов она потеряет смысл недели через 2 и станет полностью неактуальной спустя 2-3 больших поставки.

Управление продуктами удаленно

Включение расширенного запаса продуктами не особо помогло. Да, все продукты теперь были на своих складах, но, по сути – это та же таблица, которая не будет обновляться без вмешательства. Нужен был сканер.

Портативный сканер, который подключается к интернету, считывает штрих-коды и отправляет их на сервер для обработки. И за все это не придется платить. Если разобраться, то ничего сложного нет. Набросаем примерную схему:

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

Компоненты

Всю систему получилось разделить на три основных части:

  1. Небольшая Python библиотека для взаимодействия с API PrestaShop;

  2. Приложение, сканер штрих-кодов, для отслеживания перемещения товаров между складами. Ну чтоб не вписывать все каждый раз вручную;

  3. Расширение Chrome для автоматического снятия проданного велосипеда во время выставления гарантийной карты. Да, на работе все время использовался только Chrome.

Все запросы проходят через некий middle-сервер, который и связывается с сайтом для взаимодействия. Теперь обо всем вкратце и по порядку.

Главный модуль для работы с API PrestaShop

Способов взаимодействия с API PrestaShop на Python не так много, наверное, потому что PS занимает только 5% рынка (а версия 1.6 и того меньше). Нашлась всего одна полноценная библиотека— prestapyt, что само по себе большая редкость для Питона. Возможно, она сэкономила бы мне пару ночей, но попробовать свое решение хотелось не меньше чем быстрее это запустить.

Так как выбора особо и не было, а заняться было нечем, я приступил к анализу API, документациям и изучению работы PS. Спустя пару дней создался скрипт, который имел в себе самые базовые функции типа поиска продуктов и изменение их количества, да и работал умеренно быстро.

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

Код на Git

Алгоритм поиска продукта по комбинациям

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

Соответственно, для идентификации количеств необходимо получить ссылку на комбинацию, а уже потом манипулировать с ее количеством.

Исходя из этого получился следующий алгоритм:

  1. Получаем ссылку на комбинацию используя фильтр по reference коду;

  2. Из нужной комбинации — ссылку на карточку продукта;

  3. Дальше, из карточки продукта, ссылку на stock_availables. Получится некий массив ссылок;

  4. Проходим циклом по всех ссылках в associations и ищем ту же комбинацию.

  5. Получаем ссылку на стоки, проверяем наличие и, если оно > 0 — удаляем единицу товара. Если нет – выкидываем предупреждение, что товар закончился.

  6. В завершение отправляем отредактированный XML на тот же адрес stock_availables, из которого это все и было получено.

Похоже на такую себе рекурсию от комбинации аж до общего количества.

Плюсы такого подхода в том, что можно попутно захватить еще какие-то данные, скажем модель велосипеда или проверить параметры продукта.

Взаимодействие с API

Доступ к главной странице начинается со ссылки вида https://domain.com/api. Здесь можно посмотреть, какие разделы доступны авторизованному юзеру. А проверяется это попытками найти id раздела – например, тег «products» в теле ответа. Совпадение обозначает, что раздел доступен конкретному ключу.

Общение через API происходит на языке XML, а схемы запросов выглядят примерно так:

<prestashop>
	<api shopName="myshop">
		<addresses xlink:href="https://domain.com/api/addresses" get="true" put="false" post="false" delete="false" head="false"></addresses>
	</api>
</prestashop>

Следующий сегмент ссылки - это название раздела. Пример получения продуктов https://domain.com/api/products:

<prestashop>
  <products>
    <product id="22" xlink:href="https://domain.com/api/products/22"/>
    <product id="24" xlink:href="https://domain.com/api/products/24"/>
    <product id="265" xlink:href="https://domain.com/api/products/265"/>
    <product id="294" xlink:href="https://domain.com/api/products/294"/>
  <products />
<prestashop />

Отобразятся ссылки на карточки продуктов.

Для формирования и непосредственной отправки запросов скрипт использует requests. Удобная штука, хоть и работает относительно медленно Requests — потому что я работал с ней раньше, она хорошо документирована и с ней просто приятно иметь дело.

Авторизация

API PS использует Basic авторизацию только по ключу (без пароля). Поэтому запрос получается до невозможности прост. Логин средствами requests:

request_url = «https://domain.com/api»
get_combination_xml = requests.get(request_url, auth=(self.api_secret_key, ''))

 Получаем ответ 200, и тело ответа, содержащее всю страницу в XML. Теперь можно распарсить ее на отдельные теги и поискать нужные значения в них.

Парсинг XML ответа

Здесь ситуация выглядела лучше – нашлась библиотека xml.etree. Отлично документированный инструмент, который через пару минут после импорта уже выдает все, что нужно (а много и нужно), а работать с целым телом ответа можно так же, как и с обычным словарем Python.

Достанем ссылку на подкатегорию из полученного ответа:

# Импортируем все необходимое в 2 строчки
from xml.etree import ElementTree as ET
from xml.etree.ElementTree import ElementTree
# Экземпляр xml.etree


def xml_data_extractor(data, tag):
# data – XML данные
# tag = products– тег для поиска в дереве
try:
	xml_content = ET.fromstring(data.content) # Экземпляр класса ElementTree. Принимает строку в качестве аргумента. В моем случае тело ответа.
	general_tag = xml_content[0] # Получаем родительский тег – он всегда первый
	tag = general_tag.find(tag) # Ищем заданный тег
	tag_inner_link = tag.get('{http://www.w3.org/1999/xlink}href') # Ищем ссылку в теге
	# Так же можно искать подстроку href в каждом ключе и получать  значение после совпадения

  # Возвращаем внутреннюю ссылку в виде словаря
	product_meta = {'product_link': tag_inner_link}
	
  return product_meta

except:
	return None

 Результат: https://domain.com/api/products. Все просто, но работает.

Большинство функций возвращают None в блоке except и словарь в блоке Try. Не знаю, хорошо ли это, но знаю, что это можно улучшить, чтобы не было такого разброса типов.

Фильтры поиска PS

PS предоставляет фильтры для уточнения поиска. По сути, каждый фильтр – это данные для полноценного SQL запроса. Именем фильтра может быть любой тег из XML дерева страницы, а значением — его предполагаемое значение, очевидно.

Ссылка для запроса с фильтром для комбинаций выглядит так:

https://domain.com/api/combinations/?filter[reference]=reference, где reference — код искомого продукта.

Сам код - это штрих-код, наклеенный на коробке и выглядит примерно так: KRHE1Z26X14M200034.

 Дальше идут некоторые специфические функции и приватные методы, которые более подробно описаны в документации. Да! У этого куска кода есть документация на Git:

Структура «библиотеки»

Весь модуль содержит только один класс (хоть это и не читаемо, изначально я хотел работать только с общим количеством). В конструкторе кодируется в base64 секретный ключ, определяется путь до директории скрипта, создается константа времени и другие переменные, необходимые для работы с логами:

def __init__(self, api_secret_key, request_url=None, **kwargs):
	try:
		self.api_secret_key = str(api_secret_key) #!API key is necessary!
		self.api_secret_key_64 = base64.b64encode((self.api_secret_key + ':').encode())
    
except:
	raise TypeError('The secret_key must be a string!')

  
# Main path to working directory
self.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
self.request_url = request_url
self.kwargs = kwargs.items()

#product meta to return
self.name = None
self.total_quantity = None
self.w_from = None
self.w_to = None
self.date = str(datetime.datetime.now().strftime("%d-%m-%Y, %H:%M"))

Далее идут приватные методы: _xml_data_extractor(), _wd() и даже кастомный _logging(), который пишет все совершенные операции вне зависимости от результата. Можно задать свое имя лог-файла.

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

Обработка запросов и middle-сервер

Для взаимодействия приложения и расширения со скриптом из любой точки мира магазина нужен был сервер c поддержкой Python. Сначала это выглядело как проблема, но я кое-что попробовать. Поднять WSGI-сервер на хостинге, конечно же!

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

Однако, нашлось еще более скостылизированное решение. У меня есть блог, запущенный на Django, а если посмотреть под другим углом — отличный middle-сервер, способный обрабатывать множество запросов и уже настроен и готов к работе.

Создав еще одно приложение, подключил библиотеку, настроил какой-никакой контроль доступа к странице и вот он тестовый запуск.

Невалидный запрос
Невалидный запрос

Отлично! Запрос по ссылке https://palachintosh.com/xxx/xxx/? (без параметров) обработался и выдал результат об ошибке, так как нет ни токена, ни номера рамы. Просто пустой запрос.

Пробуем запрос с параметрами /?code=1122334455&token=IUFJ44KPQE342M3M109DNWI (код тестового продукта):

Пример успешного запроса
Пример успешного запроса

Вернулся словарь success – значит, запрос обработался, количество продуктов уменьшилось и на складе, и в доступных для продажи, все записалось в лог-файл и вообще работает. Уже можно слать запросы с реальными номерами рам.

Контроль доступа

За доступ к этому всему отвечает специальный токен, генерирующийся на сервере рандомайзером, и, к сожалению, вручную вписывается в файл и расширение. В будущем я хочу подключить стандартную авторизацию Django или вообще использовать Django REST. Но пока что испытываю проблемы с Java и нехваткой времени для изучения Retrofit, поэтому использую ключи.

Логика проста – токен совпадает – продолжаем операцию. Токен не совпадает – прерываем транзакцию еще на начальном этапе как-то так:

token = None

with open(‘token.txt’) as file_t:
	token = file_t[0]

if token == str(request.GET.get(‘token’)):
	//Вызываем обработчик

return JsonResponse({‘Error’: ‘Invalid token’})

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

Расширение Chrome и первые две кнопки

Расширение содержит набор функций для определения нужной страницы, отправки запроса, стилизации некоторых окон и т.д.

Положение определяет интервал, который слушает изменение ссылки в браузере и если находит совпадение передает управление функции получения данных из формы. После подтверждения в диалоговом окне формируется и отправляется запрос. Запросы шлются методом XMLHttpRequest.

Самым сложным этапом оказалась работа с политикой CORS. На сервере пришлось добавить отдельную функцию, которая добавляет заголовки Access-Control-Allow-Origin. Без него, в случае кроссдоменных запросов срабатывает защита и сразу сбрасывает соединение еще на этапе запроса OPTIONS.

Проблема решилась определением во views.py функции def options(self, request). Она просто проверяет заголовки и отдает допустимые заголовки для совершения полноценного GET запроса.

По итогу получилось вместить отправку одной формы и вывод диалогового окна в менее чем 100 строчках корявого кода, бОльшая часть которых это установка обработчиков на существующие кнопки.

Алгоритм расширения

Сканера штрих-кодов в магазине нет, но есть кое что получше. Это система выставления гарантийных карт производителя. Работает это примерно так:

Процесс выставления гарантийной карты
Процесс выставления гарантийной карты

Если вписать существующий номер рамы, то все поля заполнятся данными из реестра на стороне производителя. В поле «Kod roweru» появится тот самый код, который характеризует все одинаковые велосипеды.

Ну а дальше все просто – получаем значение поля и скармливаем его библиотеке PrestaRequest на сервере. Пускай он разбирается, что и откуда снимать.

Чтобы определить все поля и добраться до формы того самого поля «sku» пришлось немного подраться с частями страницы, которые подгружаются с помощью ajax после ввода номера рамы. По началу это было проблемой, потому что я не умею перехватывать ajax запросы в расширении. Поэтому заменил перехватчик обычным интервалом.

var interval;

function main_interval() {
    clearInterval(interval);
    
  interval = setInterval(function () {
        href = window.location.href
        if (href.indexOf('https://24.kross.pl/warranty/new') >= 0 ||
            href.indexOf('id_product=') >= 0) {

            if (href.indexOf('id_product=') >= 0) {
                prestaCheck();
                clearInterval(interval);
            }

            if (href.indexOf('https://24.kross.pl/warranty/new') >= 0) {
                location.reload();
                get_buttons();
            }
        }

        if (href.indexOf('https://24.kross.pl/bike-overview/new') >= 0) {
            clearInterval(interval);
            check_all();
        }
    }, 1000);
}

Конечный код получения данных формы выглядит так:

// onclick or enter events
 function getFormData() {
    var getForm = document.forms[0];

    if (getForm != null) {
        if (getForm.hasChildNodes("sku") && getForm.sku != null){
            var code = String(getForm.sku.value);
        }

        if (getForm.hasChildNodes("bike_model") && getForm.bike_model != null) {
            edit_msg = document.querySelector(".message-container > span > h1");
            edit_msg.innerText = "Rower " + String(getForm.bike_model.value) + " zostanie usuniety ze stanow!";
        }

        if (code != null && getForm.serial_number != null) {
            sendRequest(code);
        }
    }
}

Поле bike_model нужно исключительно для диалогового окна, в котором напоминается, над каким велосипедом производится операция. Диалоговое окно тоже добавляется на страницу скриптом расширения и выглядит так:

var getBodyBlock = document.querySelector('body');
var alert_div = document.createElement('div');

alert_div.innerHTML = '<div class="alert-message"><div class="message-container"><span><h1></h1></span><div class="inner-buttons"><button id="btnYes" class="ant-btn ant-btn-danger">Potwierdzam!</button><button id="btnReserve" class="ant-btn ant-btn-danger">Zdjac rezerwacje</button><button id="btnNo" class="ant-btn ant-btn-success">Nie teraz</button></div></div></div>';

loader = document.createElement('div');
getBodyBlock.appendChild(alert_div);

Ссылка на репозиторий: https://github.com/palachintosh/shop_extension

Самая страшная часть – Android-приложение

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

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

Работа приложения (ничего интересного)

Приложение имеет 3 страницы. Сначала выбираются названия складов и количество переносимого товара, а дальше:

  • Либо код сканится, если он не поврежден.

  • Либо вписывается вручную, если, скажем, коробка где-то далеко, но есть документ доставки.

Вообще, сам сканнер был написан не мной. Я честно нашел его на каком-то сайте и даже сохранил все комментарии автора. Но его пришлось переделать и завернуть в свое приложение, а это уже что-то добавленное от меня. К тому же переписать часть старого кода на Java и обеспечить совместимость с новыми зависимостями.

Отправкой запросов занимается библиотека Retrofit 2. Простые запросы пишутся в пару строчек, но обработка входных данных намного сложнее чем на Python. Всегда важно помнить какой тип чем обрабатывать, компилировать код, да и в самом проекте много файлов, поддиректорий, зависимостей, легко в этом потеряться с непривычки.

Кнопки в приложении

Main Activity содержит всего 3 метода – onCreate, scanCode, enterCode и описывают кнопки, которые запускают другие активити:

protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    Spinner fromSpinner = (Spinner) findViewById(R.id.fromSpinner);
    Spinner toSpinner = (Spinner) findViewById(R.id.toSpinner);

    ArrayAdapter<String> adapter = new ArrayAdapter<String> (
            this, android.R.layout.simple_spinner_item, warehouses);
    adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
    fromSpinner.setAdapter(adapter);
    toSpinner.setAdapter(adapter);
}

public void scanCode(View view) {
    Intent intent = new Intent(MainActivity.this, Scan.class);
    Spinner get_w_from = (Spinner) findViewById(R.id.fromSpinner);
    Spinner get_w_to = (Spinner) findViewById(R.id.toSpinner);
    EditText editText = (EditText) findViewById(R.id.prodctQuantity);
    String quantity_tt = editText.getText().toString();

    RequestData requestData = new RequestData(
            get_w_from.getSelectedItem().toString(),
            get_w_to.getSelectedItem().toString(),
            quantity_tt);
    intent.putExtra(RequestData.class.getSimpleName(), requestData);
    startActivity(intent);
}

Закладка “Enter Code” отвечает за ручной ввод реферального кода. Скажем, если наклейка повреждена. Здесь все просто – вписываем и отправляем.

Ручная отправка кода
Ручная отправка кода

Scan Code переключается на Activity co сканнером, а обрабатываются изображения библиотекой Barcode Scanner от Google.

Окно сканирования кода
Окно сканирования кода

Send Code – отправляющая код на сервер. Обработчик получает данные из текстового поля сканнера или инпута из активити ручного заполнения, валидирует его и передает данные обработчику отправки запроса. А Retrofit делает все остальное. Приложение, пока что, самая недопиленная часть – сказывается плохое знание Java и отсутствие опыта разработки под Android в целом, но я пытаюсь исправиться.

Код приложения на github: https://github.com/palachintosh/product_control.git

Как это выглядело раньше

Раньше процесс управления продуктами выглядел так:

  • Когда приезжала большая доставка – продукты добавлялись на сайт вручную с накладной;

  • Когда велосипед продавался – его нужно было зарегистрировать в базе производителя, записать на бумажный листок модель и дату, снять единицу продукта через админку;

  • Нужно было как-то мониторить склады, с которых коробки приезжают и стараться это обновлять по мере возможности.

Как это выглядит сейчас:

  • Когда приезжает доставка – сканируются либо штрих-коды на накладной, либо каждая коробка;

  • Во время продажи – подтверждается действие удаления единицы продукта;

  • Когда коробки мигрируют со склада на склад –в приложении выбираются склады и отправляется отсканированный код.

 В сумме, новый подход экономит около 2-х минут на каждом проданном велосипеде и гораздо больше на всех переносах склад-склад.

Выводы

Если посчитать все обязательные кнопки, то получается, что их реально 5:

  1. Отправка запроса с сайта производителя чтобы снять продукт.

  2. Отмена отправки запроса (если велосипед был продан через интернет, тогда этим управляет PrestaShop).

  3. Кнопка "Enter code" в приложении.

  4. Кнопка "Scan code".

  5. Отправка запроса в приложении – "Send Code".

Вся система действительно работает в магазине и ею пользуются даже те, кто испытывает трудности в работе с компьютером, а это, как мне кажется вполне неплохо для первого учебного проекта.

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

Из плюсов: облегчает жизнь сотрудникам и экономит время, инвентаризации на складах делаются моментально, ну и респект от шефа, конечно же.

Если статья не улетит в минуса или вообще будет опубликована, то подробно опишу код всего проекта, отрефакторю его, сделаю более безопасным и читаемым.

Людям, посмотревшим репозитории или вообще прочитавшим это буду благодарен за конструктивные комментарии и критику.