Как мы заказ обеда в офис улучшали (без доступа к серверу)

    Всем привет.

    Я работаю в офисе. Разработчиком ПО. И иногда я ем. Да что уж, каждый день. Работодатель снабжает нас обедами — работники заказывают обед на завтра, а в это завтра поставщик обедов привозит то, что работники заказали. То, что заказали и то, что привезли, не всегда совпадает, но к делу это не относится. Обед заказывается на странице заказа обедов. Но…

    Но сначала о том, как формируется страница заказа обедов: поставщик присылает XLS файл с прайсом на неделю.

    image
    Пример прайса, который присылает поставщик

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

    image
    Скриншот с заказанными обедами

    image
    Скриншот со страницей формирования заказа обеда

    Позиции неудобно разбиты по категориям. Информация о названии и составе идет сплошным текстом и сложно ориентироваться.

    Хочется понять, что лучше не заказывать, а что можно попробовать, потому что другим нравится. То есть хочется рейтинга. А еще хочется получить свой заказ в Telegram, чтобы в столовой не вспоминать, что заказал.

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

    Доступа к серверу у нас нет, поэтому изменить внешний вид страницы можно только пользовательскими скриптами. Но как быть с рейтингом? К БД доступа тоже нет. Что ж, нам нужен сервер для обработки заказов, рейтинга и взаимодействия с Telegram. На эту роль взяли NodeJS-сервер.

    Серверная часть


    Я займусь сервером, а коллега — пользовательским скриптом, добавляющим функциональность на страницу. Берем nodejs-сервер, подключаем express, добавляем MySQL. Сверху кладем Sequelize. А взаимодействовать с Telegram будем через node-telegram-bot-api:

    // Создаем новое приложение
    const app = express();
    // ...
    // Добавляем обработчики
    // Получаем пользовательский скрипт
    app.get("/dinners/user_menu", dinner.getUserMenu);
    // Получаем рейтинг по всем позициям
    app.get("/dinners/r/:id", dinner.getPersonalRatings);
    // Сохраняем рейтинг
    app.post("/dinners/r/:id", dinner.setRating);
    // Пересылаем сообщение о заказе в Telegram
    app.post("/dinners/resend/:id", dinner.resendMessage);
    // Сохраняем данные о сделанном заказе
    app.post("/dinners/order", dinner.order);
    // Устанавливаем дни, на которые нужно заказывать обед
    app.post("/dinners/days", dinner.setDinnerDays);
    

    Если коротко о функциональности:
    Путь /dinners/user_menu возвращает пользовательский скрипт:

    res.sendFile(__dirname + '/public_html/user_script.js');
    

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

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

    Далее, по пути /dinners/r/:id можно получить рейтинг по всем позициям и сохранить рейтинг, то есть проголосовать за блюда.

    Путь /dinners/resend/:id служит для передачи сообщения в Telegram. Текст сообщения формируется на клиенте, на сервере происходит лишь взаимодействие с Telegram:

    const parseMode: TelegramBot.SendMessageOptions = {parse_mode: "HTML"};
    await this.bot.sendMessage(telegramId, htmlMessage, {...options, ...parseMode});
    

    После этого Бот присылает сообщение с заказом.

    image

    Далее, по пути /dinners/order происходит сохранение заказа. Так как оригинальный запрос заказа сложно определить (после нажатия кнопки “Сохранить” появляется alert с кнопкой подтверждения заказа), то запрос на сервер с заказами отправляется при загрузке страницы заказов (а вся система заказов на сайте делится на 2 страницы — страница заказов и страница меню — выбора блюд на конкретный день — то есть формирование заказа). Это дико не рационально, посылать запросы каждый раз при входе на страницу заказов, но варианта лучше навскидку не нашлось.

    Наконец, путь /dinners/days устанавливает дни, на которые нужно заказывать обед. Эта часть функциональности появилась для корректной работы напоминаний о не сделанном заказе — нужно знать, какой следующий день заказа (ведь есть выходные и праздники посреди недели). Вместо того, чтобы взять реализацию производственного календаря, я просто разбираю даты на странице заказов, где уже помечены рабочие и нерабочие дни (нельзя сделать заказ на нерабочий день). Нерабочие дни помечаются на портале классом isHoliday:

    // Вообще это клиентская часть
    const trToday = $(".dinner_today")[0];
    const tbodyAllDays = $(trToday).parent();
    const dinnerDays = [];
    $(tbodyAllDays).children().each(async function() {
        if ($(this).hasClass("isHoliday")) {
            return;
        }
        const itemMenuDate = $(this).find("> td:first-child").text().substring(0, 10);
        dinnerDays.push(itemMenuDate);
        // ...
    });
    await sendRequest("POST", `https://****/dinners/days/`, {days: dinnerDays});
    

    О да, используем jquery для ковыряния. Очень удобно копаться в дереве страницы.

    Telegram-бот


    Еще одна часть всей надстройки — telegram-бот.

    image
    С вот такой функциональностью

    Получить ID — это такая система идентификации. Чтобы связать пользовательский скрипт на конкретном браузере с userId в telegram.

    Посмотреть заказ на сегодня, посмотреть список заказов (последние 5), установить напоминание.
    Обед в автоматическом режиме отправляется поставщику в одно и то же время каждый день, поэтому важно делать заказ до определенного времени, скажем, 13:00.

    После этого возможность сделать заказ блокируется.

    Напоминания:

    image
    Бот предоставляет возможность выбрать время напоминания: 9, 10 или 11 часов.

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

    Это сделано cron-задачей (используем node-schedule):

    schedule.scheduleJob('*/10 9-13 * * 1-5', async function() {
        // ...
    });
    

    Клиентская часть. Меню


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

    Поискав по просторам интернета что может нам помочь, наткнулись на вполне неплохой плагин для пользовательских скриптов Greasemonkey, им и решили воспользоваться.

    Первым делом создаем пользовательский скрипт и даем права общаться с корпоративным порталом и сервером, на котором прикручен рейтинг и возможность отправлять запросы

    // @include  http://****.int/*
    // @include  http://****/*
    // @grant GM.xmlHttpRequest
    

    Так же для модификации самой страницы обедов мы воспользовались jQuery, подключив его посредством // @require

    Теперь начнем перелопачивать страницу обедов. Посмотрев html код страницы, находим идентификатор таблицы обедов, получаем таблицу и модифицируем.

    const table = $(".dinner__innerData");
    const categoryList = [];
    // Проходимся по всем названиям категорий
    $(table).find(“tbody tr td:nth-child(2})”).each(function () {
        const text = $(this).text();
        // Перед первой строкой новой категории добавляем строку с названием категории
        if (!categoryList.find(name => name === text)) {
            $(this).parent().before("<tr><th colspan='6'>" + text + "</th><th style='display:none'></th><th style='display:none'></th><th style='display:none'>0</th><th style='display:none'><span class='dish__amount'>0</span></th></tr>");
            categoryList.push(text);
        }
    });
    // Удаляем колонку с категорией блюда
    $(table).find(“thead th:nth-child(2)”).remove();
    $(table).find("tbody tr td:nth-child(2)”).remove();
    // Добавляем колонку с рейтингом
    $(table).find(“tbody tr td:nth-child(2)”).after("<td></td>");
    $(table).find(“thead th:nth-child(2)”).after("<th class='ui-state-default'>Рейтинг</th>");
    

    Хочу отметить, что на странице формирования обедов, при подсчете суммы заказа, она считается по всем строкам таблицы, получая число заказанного пункта, умноженное на цену. По этим причинам, если добавить строку с названием категории — всё сломается… Пришлось вводить скрытые столбцы с нулевым количеством и суммой для этой строки.

    Теперь перейдем к чистке текста и добавлению информации по рейтингу блюда. Для начала несколько вспомогательных функций. Блюдо в рейтинге идентифицируется по названию без всякого мусора в виде граммов, всяких символов препинания и пробелов. То есть блюдо с названием “Бульон куриный с яйцом (бульон куриный, морковь, лук, яйцо, зелень). В 100гр: белки-3,43; жиры-2,86; углеводы-1,0; эн.ценность-43,39ккал (200гр)” идентифицируется как “бульонкуриныйсяйцом”. Это связано с тем что у поставщика могут закрадываться лишние пробелы, знаки и ещё что-нибудь. Как показала практика, этого было достаточно, чтобы точно идентифицировать в 90% случаев блюдо, и мы решили не заморачиваться и не вводить полнотекстовый поиск.

    /**
     * Поиск элемента в рейтинге по имени в таблице
     * @param items  элементы рейтинга
     * @param tdText текст в таблице
     * @return элемент рейтинга
     */
    function findByName(items, tdText) {
        tdText = clearTrash(tdText, true, true, true);
        return items.find(({clear_name}) => {
            return clear_name.trim().toLowerCase() === tdText;
        });
    }
    
    /**
     * Очистить мусор из названий
     * @param text       название
     * @param clearDescr признак очистки того что в скобках
     * @param clearGrams признак удаления граммы
     * @return название без мусора
     */
    function clearTrash(text, clearDescr, clearGrams, clearSymbols) {
        // Обычный парсинг строки, на котором заострять внимание не будем
    }
    

    А это формирование рейтинга:

    const table = $(".dinner__innerData");
    const nameTd = $(table).find(“tr td:nth-child(2)”);
    for (let index = 0; index <= nameTd.length; index++) {
        const tdText = $(nameTd[index]).text();
        // Ищем позицию в рейтинге
        const item = findByName(items, tdText);
    
        if (item) {
            let ratingTd = $(nameTd[index]).parent().find(“td:nth-child(2)”)[0];
            // Добавляем информацию об общем рейтинге и личном с количествами заказов
            let ratingText = "<i>о</i> " + parseFloat(item.avgrating).toFixed(1) + " (заказов: " + item.orders + ", чел: " + item.ratingsCount + ")";
            ratingText = item.persrating ? `<b><i>л</i> ${parseFloat(item.persrating).toFixed(1)} (заказов: ${item.perscount})</b><br>` + ratingText : ratingText;
           // Устанавливаем рейтинг
           $(ratingTd).css({
    	// getColorRating возвращает цвет в зависимости от рейтинга
                background: getColorRating(item.avgrating)
          }).html(ratingText);
        }
        // Из названия блюда получаем мало полезную информацию в виде граммов
        // Мы её оставим, но в более подходящем отображении
        const grams = getGrams(tdText);
        // Чистим наименования от граммовки
        $(nameTd[index]).html(clearTrash(tdText, false, true, false));
        // Добавляем граммы в ту же ячейку, но строкой ниже и меньшим размером
        $(nameTd[index]).append("<br/><span></span>")
                                    .find("span")
                                    .append(grams)
                                    .css({"font-size": 10});
    }
    

    И вот что получилось.

    image
    Согласитесь, гораздо приятнее и удобнее?

    Клиентская часть. Голосование


    Далее перейдем к добавлению возможности голосовать за заказанные блюда, а так же высылать сообщение с заказом в telegram.

    image
    Страница с заказами без скрипта

    На странице заказанных блюд добавляем рейтинг:

    async function addRatingForm() {
        const table = $(".dinner__innerData");
        const nameTd = $(table).find("tr td:nth-child(1)");
        // Чистим текст
        for (let index = 0; index <= nameTd.length; index++) {
            const tdText = $(nameTd[index]).text();
            $(nameTd[index]).html(clearTrash(tdText, false, true, false));
        }
        // Добавляем кнопку Проголосовать и Отправить в Telegram
        $(table).append("<tfoot><tr><th colspan='6' class='rating-buttons btn-group margT0' style='display: table-cell;'></tr></tfoot>");
        $(".rating-buttons").prepend(`<input type="submit" value="Проголосовать" class="btn_primary rating-button">`);
        $(".rating-buttons").prepend(`<input type="submit" value="В Telegram" class="btn_primary send-button">`);
        // Отключаем голосование если уже голосовали
        await diableButtonByDate();
        // Добавляем форму рейтинга для блюда
        for (let index = 0; index <= table.length; index++) {
            $(table[index]).find("tbody tr td:nth-child(4)").after("<td class='ratingInputTd'><input id='horizontal-spinner' class='ui-spinner-input' style='width:20px;'></td>");
            $(table[index]).find("thead th:nth-child(4)").after("<th class='ui-state-default'></th>");
        }
        $(".ui-spinner-input").spinner({
             max: 10,
             min: 1
        });
        // Устанавливаем обработчики
        $(".rating-button").click(sendRating);
        $(".send-button").click(sendTelegram);
    }
    
    /**
     * Дизейблим кнопку голосования для определенной даты, если уже голосовали
     */
    async function diableButtonByDate() {
        // Просто идем по всем кнопкам и проверяем голосовали ли мы в этот день.
        // Благо у нас есть дата заказа в таблице и даты заказов в кеше
        const buttons  = $(".rating-button");
        for (let index = 0; index <= buttons.length; index++) {
            const button = $(buttons[index]);
            const date = button.parent().parent().parent().parent().parent().parent().find("> td:nth-child(1)").text().substring(0, 10);
            if (await GM.getValue(date)) {
                button.attr({disabled: "disabled"});
            }
        }
    }
    
    /**
     * Проголосовать
     */
    async function sendRating(event) {
        event.preventDefault();
        const items = [];
        // Собираем все рейтинги из формы и формируем запрос
        $(this).parent().parent().parent().parent().find("tr").each(function () {
            const tdList = $(this).find("td");
            const ratingInput = $(tdList[4]).find("input");
            if (!ratingInput.length) {
                return;
            }
            items.push({
                 count: $(tdList[2]).text(),
                 price: $(tdList[1]).text(),
                 name: $(tdList[0]).text(),
                 rating: ratingInput.val(),
            });
         });
        await sendRequest("POST", `https://****/dinners/r/${telegramId}`, items);
        const menuDate = $(this).parent().parent().parent().parent().parent().parent().find("> td:nth-child(1)").text().substring(0, 10);
        await GM.setValue(menuDate, true);
        location.reload();
    }
    

    И вот что получили мы на выходе:

    image

    Да — код ужасен. Да — не оптимизирован. И да — местами нелогичен. Но потрачено времени при этом было по минимуму, а функциональность и удобство значительно возросли.

    Цель была сделать заказ обеда приятнее для себя и товарищей и эта цель, на мой взгляд, была достигнута.

    Комментарии 7

      0
      На последнем скриншоте, в заказе на вторник есть бага: сумма заказа должна быть посередине между 2 и 3 строкой, а она точно на второй.
        0
        Не скажу наверняка, но кажется сумма проставляется в номер строки округлить(кол-во_строк/2), то есть если строк 6, то во третью. Если строк 5, то тоже в третью (округление в большую сторону). Фича же.
          0

          rowspan?

            0
            Это возможный вариант решения?
            Потому что сейчас там просто пустые ячейки рендерятся, и на одной из них ставится сумма =)
              0

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

                0
                Да, rowspan позволяет объединить ячейки и прописать текст. Далее уже стандартными методами говорим, что текст по центру рисовать.
                Альтернативно — рисуем в фиксированную ячейку. В последнюю, например. Или в первую.
          0
          Нет, ну казино через «умный» термостат для аквариума уже ломали, в будущем может увидим новости про то, как контору целиком ломанули через user_script.js, задуманный ради заказа обедов.

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

          Самое читаемое