MAATRIX / Блог / Снабженец держит прайсы 200 поставщиков: свой поиск по прайс-листам

Снабженец держит прайсы 200 поставщиков: свой поиск по прайс-листам

MAATRIX

У снабженца с парком в пару сотен поставщиков рабочий день часто начинается не с закупки, а с поиска: «а у кого вообще есть эта позиция и почём». Прайсы приходят на почту, в мессенджеры, лежат в папке «Прайсы 2026» вперемешку — xlsx, csv, pdf-сканы, иногда фото прайс-листа с выставки. Найти конкретный артикул среди двух сотен файлов вручную — это открыть штук двадцать из них, прежде чем повезёт. Решение не в том, чтобы завести ещё одну таблицу, а в том, чтобы поднять свой сервер с полнотекстовым поиском, который индексирует все накопленные прайсы и отвечает на запрос «у кого есть товар X» за секунду.

Почему это реальная боль, а не бухгалтерская мелочь

Прайс-лист от каждого поставщика — это отдельный файл со своей структурой: у одного колонки «Артикул / Наименование / Цена», у другого «Код / Товар / Цена с НДС / Цена без НДС», у третьего вообще PDF, свёрстанный дизайнером, где данные — не таблица, а картинка текста. Обновляются они с разной частотой: кто-то присылает новый файл раз в неделю, кто-то раз в квартал, а кто-то вообще не предупреждает и просто перезаписывает файл на своём сайте.

Когда поставщиков десять — с этим ещё можно жить: помнить в лицо, у кого что искать, держать закладки. Когда их 200 (число условное — у кого-то будет 80, у кого-то 400, суть от этого не меняется), ручная модель ломается физически. Снабженец либо:

  • держит в голове куцую выжимку «кто чем торгует» и по памяти идёт проверять 5-10 файлов из полутора сотен;
  • заводит сводную таблицу вручную и обновляет её раз в месяц, к моменту следующего обновления она уже наполовину врёт;
  • звонит и пишет поставщикам напрямую с вопросом «а есть ли у вас…», теряя на переписке то время, которое должен был сэкономить прайс-лист.

Каждый из трёх вариантов — это упущенная выгода: пока вы вручную листаете файлы, конкурент с автоматизированным поиском уже отправил заявку тому, у кого дешевле и есть в наличии. Задача решается не организационно (ещё одна инструкция «обновляйте таблицу вовремя»), а технически — построить единый индекс, куда автоматически попадает содержимое всех прайсов, и который отвечает на текстовый запрос списком: поставщик, цена, дата обновления файла.

Почему Excel и папка с файлами не спасают

Первая реакция — свести всё в одну большую Excel-таблицу. Это работает ровно до тех пор, пока прайсов не больше 15-20 и их формат не меняется. На двух сотнях источников сведение вручную превращается в отдельную работу на полный день каждую неделю: скопировать столбцы, привести к одному виду единицы измерения, не перепутать, где цена с НДС, а где без. Любая ошибка копирования — это неверная цена в решении о закупке.

Поиск средствами самого Excel (Ctrl+F по каждому файлу) тоже не годится: это линейный перебор, который надо повторить 200 раз для одного запроса, и он ищет точное совпадение строки — «шуруп 4х40» и «шуруп 4.0х40» для него разные вещи, хотя это один и тот же товар у двух поставщиков.

Папка с файлами на сетевом диске или в облаке (Google Drive, Яндекс.Диск) решает проблему хранения, но не поиска: встроенный поиск по названию файла не заглянет внутрь xlsx и тем более внутрь PDF-скана. Поиск по содержимому в таких сервисах либо отсутствует, либо работает через раз и не понимает специфику прайс-листа — что первая колонка это артикул, а третья это цена.

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Идея: единый индекс поиска по всем прайсам

Смысл решения простой: вместо того чтобы искать по 200 отдельным файлам, нужно один раз распарсить каждый из них в одинаковую структуру (артикул / наименование / цена / поставщик / дата обновления) и загрузить всё в поисковый движок с полнотекстовым индексом. После этого запрос «болт м10» за долю секунды возвращает список всех поставщиков, у кого есть позиция с таким или похожим названием, с ценой и датой, когда прайс обновлялся последний раз.

Для этой задачи не нужен тяжёлый Elasticsearch с JVM и кластером — это оверинжиниринг для каталога на несколько сотен тысяч строк. Разумный выбор — Meilisearch: разворачивается за несколько минут, из коробки терпит опечатки (снабженец может написать «шуруп саморез» вместо точного названия из прайса и всё равно получить релевантный результат), не требует отдельной команды для поддержки. Если каталог заметно крупнее — миллионы строк с высокой нагрузкой на фильтрацию — стоит посмотреть сравнение Meilisearch и Typesense, но для задачи снабженца с парой сотен прайсов Meilisearch обычно достаточно с запасом.

Схема целиком:

  1. Прайсы (xlsx, csv, PDF) складываются в одну папку на сервере — вручную или автоматически из почты.
  2. Скрипт нормализации разбирает каждый файл, приводит колонки к общему виду и складывает результат в JSON.
  3. JSON заливается в Meilisearch одним запросом на каждый прайс.
  4. Снабженец ищет через простую веб-форму или напрямую через API — по названию, артикулу, части названия.

Разворачиваем поиск на сервере

Поднимаем Meilisearch в Docker — так проще обновлять и не тащить лишние системные зависимости. Пошаговая установка без Docker разобрана отдельно в статье про установку Meilisearch на VPS, а здесь — минимальный docker-compose.yml для нашей задачи (более подробный вариант — в статье Meilisearch в Docker Compose):

services:
  meilisearch:
    image: getmeili/meilisearch:v1.10
    restart: unless-stopped
    environment:
      MEILI_MASTER_KEY: "положите-сюда-длинный-случайный-ключ"
      MEILI_ENV: production
    ports:
      - "127.0.0.1:7700:7700"
    volumes:
      - meili_data:/meili_data

volumes:
  meili_data:

Порт наружу не открываем — только на localhost, доступ снаружи через Nginx с базовой авторизацией или через VPN, потому что цены поставщиков — это коммерческая информация, которая не должна быть доступна из интернета всем подряд.

Поднимаем и создаём индекс:

docker compose up -d

curl -X POST 'http://127.0.0.1:7700/indexes' \
  -H "Authorization: Bearer $MEILI_MASTER_KEY" \
  -H 'Content-Type: application/json' \
  --data-binary '{"uid": "prices", "primaryKey": "id"}'

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

curl -X PUT 'http://127.0.0.1:7700/indexes/prices/settings' \
  -H "Authorization: Bearer $MEILI_MASTER_KEY" \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "searchableAttributes": ["name", "article", "supplier"],
    "filterableAttributes": ["supplier", "price", "updated_at"],
    "sortableAttributes": ["price", "updated_at"],
    "typoTolerance": {"enabled": true, "minWordSizeForTypos": {"oneTypo": 4, "twoTypos": 8}}
  }'

Ресурсы под такую задачу нужны скромные — сотни тысяч строк прайсов Meilisearch держит на минимальном VPS без проблем; если каталог вырастет на порядок, ориентиры по памяти есть в статье сколько RAM нужно для Meilisearch.

Приводим разнобой прайсов к одному виду

Это самая содержательная часть работы — без неё поисковый движок нечего будет индексировать. У каждого поставщика свой формат, и задача скрипта — вытащить из любого файла три вещи: название товара, цену, артикул (если есть), и добавить к ним имя поставщика и дату файла.

Для xlsx и csv хватает pandas с автоопределением листа и колонок по ключевым словам в заголовке:

import pandas as pd
import re
from pathlib import Path
from datetime import datetime

def guess_column(columns, keywords):
    for col in columns:
        low = str(col).lower()
        if any(k in low for k in keywords):
            return col
    return None

def parse_price_file(path: Path, supplier: str) -> list[dict]:
    df = pd.read_excel(path) if path.suffix in (".xlsx", ".xls") else pd.read_csv(path)
    name_col = guess_column(df.columns, ["наименование", "товар", "название"])
    price_col = guess_column(df.columns, ["цена", "стоимость", "price"])
    article_col = guess_column(df.columns, ["артикул", "код", "sku"])

    if not name_col or not price_col:
        raise ValueError(f"Не нашёл нужные колонки в {path.name}")

    updated_at = datetime.fromtimestamp(path.stat().st_mtime).isoformat()
    rows = []
    for i, row in df.iterrows():
        price_raw = re.sub(r"[^\d.,]", "", str(row[price_col])).replace(",", ".")
        try:
            price = float(price_raw)
        except ValueError:
            continue
        rows.append({
            "id": f"{supplier}-{i}",
            "name": str(row[name_col]).strip(),
            "article": str(row[article_col]).strip() if article_col else "",
            "price": price,
            "supplier": supplier,
            "updated_at": updated_at,
        })
    return rows

Для PDF-прайсов, если это текстовый PDF (не скан), достаточно pdfplumber для извлечения таблиц. Если это скан или фото прайс-листа — без OCR не обойтись, здесь пригодится Paperless-ngx: он распознаёт текст на сканах и хранит их с полнотекстовым поиском по содержимому, можно использовать его как первый слой распознавания, а результат уже парсить тем же способом, что и обычный текст.

Дальше — заливка распарсенных строк в индекс одним запросом на файл:

import requests

def push_to_meilisearch(rows: list[dict]):
    requests.post(
        "http://127.0.0.1:7700/indexes/prices/documents",
        headers={"Authorization": f"Bearer {MASTER_KEY}"},
        json=rows,
    )

Важный нюанс: при повторной загрузке прайса от того же поставщика старые строки нужно сначала удалить (DELETE /indexes/prices/documents с фильтром supplier = "название"), иначе в индексе останутся позиции, которых давно нет в актуальном прайсе, и снабженец будет находить товар, которого поставщик уже не продаёт.

Автоматизация: чтобы прайсы не грузить руками

Если каждый новый прайс придётся заливать вручную командой в терминале, через месяц эта система заброшена — ровно так же, как заброшена сводная Excel-таблица. Смысл появляется только тогда, когда обновление происходит само.

Практичная схема — n8n на том же сервере: workflow слушает почтовый ящик, куда приходят прайсы (у многих поставщиков это отдельный адрес рассылки), при получении вложения xlsx/csv/pdf сохраняет файл в папку по имени поставщика и запускает скрипт нормализации. Отдельным cron-джобом раз в сутки можно сканировать папку целиком и догружать файлы, которые попали туда не через почту — например, скачанные вручную с сайта поставщика:

# /etc/cron.d/reindex-prices
0 3 * * * supplier-bot /opt/prices/venv/bin/python /opt/prices/reindex.py >> /var/log/prices-reindex.log 2>&1

Скрипт reindex.py проходит по всем файлам в папке, для каждого поставщика удаляет его старые записи из индекса и загружает свежие — так индекс всегда отражает последнюю версию каждого прайса, а не смесь из старых и новых цен.

Для интерфейса самому снабженцу не нужен отдельный фронтенд — Meilisearch отдаёт готовый минимальный UI для поиска по индексу, а если нужен более удобный вид с фильтрами по поставщику и сортировкой по цене, несложную HTML-страницу с обращением к API можно сделать за пару часов и разместить на том же сервере за Nginx.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Что делать с прайсами в WhatsApp и Telegram, а не на почте?

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

Нужно ли распознавать сканы прайсов через OCR, если их немного?

Если сканов и фото единицы, проще один раз вручную перепечатать нужные позиции в csv. OCR через Paperless-ngx оправдан, когда таких файлов десятки и они регулярно обновляются.

Что если у поставщика в прайсе нет столбца с артикулом?

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

Как быть с ценами, которые различаются по объёму закупки (опт/розница)?

Добавьте в структуру данных отдельное поле с минимальной партией или типом цены и сделайте его filterable в настройках индекса — тогда в поиске можно будет сразу отфильтровать нужный уровень цены.

Сколько времени займёт первоначальная загрузка 200 прайсов?

Зависит от того, насколько разнородны форматы — если у большинства поставщиков структура похожая (артикул/название/цена), скрипт нормализации напишется быстро и прогон всех файлов займёт минуты; если форматы сильно расходятся, часть времени уйдёт на ручную доводку парсера под конкретных «сложных» поставщиков.

Безопасно ли держать цены поставщиков на арендованном сервере?

Да, если сервер настроен как обычно для рабочих данных: доступ только по SSH-ключу, поисковый API закрыт от внешнего мира и доступен через VPN или авторизацию на Nginx, регулярные бэкапы — для Meilisearch это описано в статье про бэкап и восстановление Meilisearch.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →