Voltera.kz · Казахстан · 2026

Бэкенд, который продаёт
измерительные приборы сам

Django-платформа для B2B-каталога КИП: поиск с фасетами на Elasticsearch, сборка коммерческих предложений с печатью в PDF, автогенерация SEO через Claude API и уведомления менеджерам в Telegram. Один разработчик, шесть месяцев, 429 зелёных тестов.

35 594
строк Python
25
доменных модуля
429
тестов, зелёных
214
коммитов
2
языка: ru / kk
Задача

Каталог на 1400 позиций, где цену не пишут, а запрашивают

Измерительная техника — не футболки. У товара двадцать характеристик, класс точности, поверка, комплектации и аксессуары; цена зависит от курса и согласуется индивидуально. Клиент оставляет запрос — дальше менеджер вручную собирает коммерческое предложение в Word, пересчитывает валюту по вчерашнему курсу и отправляет файл, о судьбе которого больше ничего не знает.

Задача была не «сделать интернет-магазин», а закрыть весь путь: от того, как товар находят в поиске, до того, как менеджер узнаёт, что клиент открыл КП с телефона два раза за вечер.

Витрина

49 эндпоинтов публичного API

21 доменный роутер на django-ninja с автоматической OpenAPI-схемой. Ответы сериализует orjson — в 5–10 раз быстрее стандартного json.

Контент

Админка как рабочее место

5 200 строк кастомного Django-admin: MPTT-дерево категорий, вложенные инлайны, кроп изображений, CKEditor 5, конструктор КП.

Двуязычность

ru / kk на уровне модели

django-parler: переводы в отдельных таблицах, а не в JSON-поле. Поиск, sitemap, JSON-LD и ИИ-генерация текстов знают про оба языка.

Эксплуатация

Четыре процесса, один образ

web, taskiq-worker, taskiq-scheduler и telegram-bot собираются из одного Dockerfile и отличаются только командой запуска.

Архитектура

Границы слоёв проверяет линтер, а не сила воли

«Роутер не ходит в модели» — правило, которое живёт ровно до первого дедлайна, если его никто не проверяет. Здесь оно записано в .importlinter и падает в CI: два контракта на 27 корневых пакетов.

api/v1/
HTTP — роутеры, схемы, коды ответов, OpenAPI. Ни одного импорта доменных моделей.
api/services/
Бизнес-логика — 20 сервисов, запросы к БД, prefetch, сборка ответа. Единственная дверь между HTTP и данными.
app_<domain>/
Домен — 25 приложений: models, admin, signals, tasks. Про существование API не знают.
core/ + инфра
Платформа — настройки, брокер, SEO-слой; PostgreSQL, Elasticsearch, Redis, Taskiq, Chromium.
FORBIDDEN api.v1app_*  Прямой импорт модели в роутере — это бизнес-логика, протёкшая в транспорт.
FORBIDDEN app_*api  Зависимости идут вниз. Домен, знающий про HTTP, — это цикл и модель, непереиспользуемая вне API.
Инженерные решения

Шесть мест, где интересное началось

01

Фасеты, которые сужаются умно

Наивный фасетный поиск ломается на втором клике: выбрал бренд — и список брендов схлопнулся до одного. Здесь запрос собран по схеме post_filter + filtered aggregations: выдача фильтруется после агрегаций, а каждая агрегация видит все активные фильтры кроме своего.

Тонкость — в характеристиках. «Своё» для них не вся группа, а конкретный ключ: выбрал «USB: есть» — значения USB остаются полными, а «Вес» и «Режим DRM» сужаются. Одна terms-агрегация так не умеет, поэтому на каждый выбранный ключ уходит отдельная секция, а форматтер склеивает их обратно в плоский список.

Фасеты кэшируются в Redis на час по детерминированному ключу из языка и всех фильтров. При попадании в кэш запрос в ES уходит вообще без агрегаций — только хиты.

02

vekmnbvtnh — это «мультиметр»

Половина запросов в B2B-поиске набирается не глядя на экран. Модуль layout.py переставляет раскладку позиционно, ЙЦУКЕН ↔ QWERTY, без словарей и эвристик — и работает в обе стороны: «ьуфыгку» превращается в «measure», когда искали бренд, не переключившись на латиницу.

В Elasticsearch уходят три именованные ветки запроса — оригинал, префикс и подменённая раскладка. По matched_queries в хитах видно, какая сработала, — из этого строится подсказка «возможно, вы искали».

Пустая выдача из-за забытой раскладки перестала существовать как класс проблемы.

03

КП — это снимок, а не ссылка

Коммерческое предложение — денежный документ. Если позиция ссылается на живую карточку товара, то КП, отправленное месяц назад, завтра покажет другой класс точности и другую сумму: кто-то поправил каталог или курс валюты.

Поэтому в момент сборки черновика в позицию копируется всё: название, артикул, характеристики, комплектация, фото, цена, валюта и — отдельно — rate_to_offer_currency, замороженный курс. Ссылка на товар остаётся, но только чтобы знать, что позиция из системы. Внесистемный товар — та же позиция с пустым product, второй сущности не понадобилось.

Одобренное КП не пересобирается: правки идут новой версией, прежняя помечается «заменено». Клиент никогда не увидит, как документ по той же ссылке тихо изменился.

04

PDF печатается из сохранённого HTML

PDF и публичная ссылка на КП обязаны быть одним и тем же документом. Пересборка из данных дала бы второй результат, который со временем разъедется с первым, — поэтому печатается именно сохранённый HTML.

Печатает системный Chromium из образа, Playwright — только способ им управлять. WeasyPrint отпал: шаблон использует grid и display:contents, движок сверстал бы его иначе и пришлось бы вести вторую вёрстку. Перед печатью внешние стили отключаются, ссылки на media превращаются в локальные файлы, служебные элементы убираются — иначе Chromium ждёт сеть, которой в контейнере может не быть.

Шрифты IBM Plex лежат в репозитории и ставятся в образ: подключение с CDN во время печати означало бы PDF с пустыми прямоугольниками вместо кириллицы.

05

Просмотр КП: сессия, а не запрос

Менеджеру важно знать, открыл ли клиент документ. Но перезагрузка и кнопка «назад» — не новые просмотры, а мессенджеры дёргают ссылку сами, чтобы построить превью. Сессия здесь — пара (IP, User-Agent) в получасовом окне, а is_human ставится только по реальному сигналу со страницы: скроллу или клику.

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

В Telegram менеджеру приходит: «КП открыли с двух устройств, 3 раза» — с прямой ссылкой на документ.

06

SEO-тексты пишет Claude, факты проверяет код

У 1142 товаров из 1408 seo_title дублировал название, а в seo_description лежала вся портянка описания, которую Google обрезает на 160 символах. Модуль core/seo_ai.py ничего не знает про Django-модели: на вход — тип страницы и плоский словарь фактов, на выходе — {язык: {title, description}} по строгой JSON-схеме, с контролем длин и ретраем.

Отдельная команда estimate_seo_cost считает очередь и по выборке реальных запросов даёт смету на весь прогон, ничего не записывая в базу: модель выбирается по замеру, а не по ощущению.

Разметка JSON-LD собирается кодом, не моделью: 1 400 строк seo_jsonld.py строят Product, BreadcrumbList, Article и sitemap-индекс с <image:image> из одного источника URL — чтобы карта сайта и микроразметка не разошлись, как это уже однажды случилось.

Отказоустойчивость

Ни одна внешняя зависимость не роняет витрину

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

СервисЕсли недоступен
ElasticsearchРучка /search/ отдаёт пустой результат и пишет лог. Каталог, карточки и КП работают. Жёсткой зависимости нет по контракту.
RedisIGNORE_EXCEPTIONS=True: кэш фасетов просто перестаёт работать, запросы идут в ES напрямую.
TelegramБез токена уведомления пишутся в лог. Бот — отдельный процесс на long polling; его падение не мешает системе, потому что исходящие шлёт taskiq прямыми запросами к Bot API.
Anthropic APIБез ключа автогенерация SEO не запускается — заполненные вручную поля остаются на месте.
РеиндексацияПадение es_reindex на старте не блокирует деплой: контейнер поднимается и отдаёт трафик.
Фоновые задачи

Taskiq вместо Celery

Нативный async, брокер на Redis, cron прямо в декораторе задачи. Ежедневные задачи идемпотентны и переживают пропущенные дни: лежавший сутки воркер не даёт ни дублей, ни пропусков.

Аналитика поиска

Rotate + drain

Счётчики запросов копятся в Redis и раз в период атомарно переименовываются в counts:flushing: новые инкременты уже идут в свежий ключ, ничего не теряется. Upsert через F(), флаш под коротким локом.

Стек

Ничего лишнего, всё обосновано

Ядро
Python 3.11Django 5 django-ninjaPydantic-схемы orjsonPoetry
Данные
PostgreSQLElasticsearch 8 Redisdjango-parler django-mptt
Асинхронное
Taskiqtaskiq-redis aiogram 3asgiref
Документы
PlaywrightChromium PyMuPDFpypdf CKEditor 5
ИИ
Anthropic APIJSON-схемы ответа учёт токенов
Эксплуатация
Docker ComposeGunicorn nginxruff import-linter
Как это делалось

«Написал и уверен» — не доказательство

В репозитории лежит инженерный регламент, по которому идёт каждая сессия работы. Задача не выполнена, пока make check не вышел с кодом 0.

ruffЛинт и форматирование — единый стиль на 506 файлов, без споров о запятых.
lint-importsДва архитектурных контракта. Слой, полезший не туда, ломает сборку, а не ревью.
test429 тестов, 5 586 строк — на реальном Postgres в docker-compose, а не на SQLite.
PROGRESSСостояние, следующие шаги и блокеры фиксируются между сессиями. Кончается контекст — останавливаемся на чистом коммите, а не доделываем второпях.
commitОдин коммит — одно законченное изменение. В сообщении «зачем», потому что «что» видно из диффа.
Известные ловушки — записаны

parler ломает пагинацию

Фильтр по переводимому полю разворачивается в джоин с таблицей переводов и даёт дубли. Лечится translations__<field> + language_code + .distinct() — и это записано в регламент, а не в чью-то память.

Известные ловушки — записаны

N+1 не по недосмотру

Сервисы отдают списки. Любое новое обращение к связанной модели идёт в select_related/prefetch_related базового queryset, а не в цикл сериализации.

Дальше

Нужен бэкенд, который переживёт третий год эксплуатации?

Я беру проекты целиком: от схемы данных и границ слоёв до Docker-образа, который поднимается на пустом сервере одной командой. Каталоги, B2B-порталы, интеграции, автоматизация того, что менеджеры сегодня делают руками.

Обсудить проект →