Бэкенд, который продаёт
измерительные приборы сам
Django-платформа для B2B-каталога КИП: поиск с фасетами на Elasticsearch, сборка коммерческих предложений с печатью в PDF, автогенерация SEO через Claude API и уведомления менеджерам в Telegram. Один разработчик, шесть месяцев, 429 зелёных тестов.
Каталог на 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 → app_* Прямой импорт модели в роутере — это бизнес-логика, протёкшая в транспорт.
app_* → api Зависимости идут вниз. Домен, знающий про HTTP, — это цикл и модель, непереиспользуемая вне API.
Шесть мест, где интересное началось
Фасеты, которые сужаются умно
Наивный фасетный поиск ломается на втором клике: выбрал бренд — и список
брендов схлопнулся до одного. Здесь запрос собран по схеме
post_filter + filtered aggregations: выдача фильтруется после
агрегаций, а каждая агрегация видит все активные фильтры кроме своего.
Тонкость — в характеристиках. «Своё» для них не вся группа, а конкретный ключ: выбрал «USB: есть» — значения USB остаются полными, а «Вес» и «Режим DRM» сужаются. Одна terms-агрегация так не умеет, поэтому на каждый выбранный ключ уходит отдельная секция, а форматтер склеивает их обратно в плоский список.
Фасеты кэшируются в Redis на час по детерминированному ключу из языка и всех фильтров. При попадании в кэш запрос в ES уходит вообще без агрегаций — только хиты.
vekmnbvtnh — это «мультиметр»
Половина запросов в B2B-поиске набирается не глядя на экран. Модуль
layout.py переставляет раскладку позиционно, ЙЦУКЕН ↔ QWERTY,
без словарей и эвристик — и работает в обе стороны: «ьуфыгку» превращается
в «measure», когда искали бренд, не переключившись на латиницу.
В Elasticsearch уходят три именованные ветки запроса — оригинал, префикс и
подменённая раскладка. По matched_queries в хитах видно, какая
сработала, — из этого строится подсказка «возможно, вы искали».
Пустая выдача из-за забытой раскладки перестала существовать как класс проблемы.
КП — это снимок, а не ссылка
Коммерческое предложение — денежный документ. Если позиция ссылается на живую карточку товара, то КП, отправленное месяц назад, завтра покажет другой класс точности и другую сумму: кто-то поправил каталог или курс валюты.
Поэтому в момент сборки черновика в позицию копируется всё: название,
артикул, характеристики, комплектация, фото, цена, валюта и — отдельно —
rate_to_offer_currency, замороженный курс. Ссылка на товар
остаётся, но только чтобы знать, что позиция из системы. Внесистемный товар —
та же позиция с пустым product, второй сущности не понадобилось.
Одобренное КП не пересобирается: правки идут новой версией, прежняя помечается «заменено». Клиент никогда не увидит, как документ по той же ссылке тихо изменился.
PDF печатается из сохранённого HTML
PDF и публичная ссылка на КП обязаны быть одним и тем же документом. Пересборка из данных дала бы второй результат, который со временем разъедется с первым, — поэтому печатается именно сохранённый HTML.
Печатает системный Chromium из образа, Playwright — только способ им управлять.
WeasyPrint отпал: шаблон использует grid и display:contents, движок
сверстал бы его иначе и пришлось бы вести вторую вёрстку. Перед печатью внешние
стили отключаются, ссылки на media превращаются в локальные файлы, служебные
элементы убираются — иначе Chromium ждёт сеть, которой в контейнере может не быть.
Шрифты IBM Plex лежат в репозитории и ставятся в образ: подключение с CDN во время печати означало бы PDF с пустыми прямоугольниками вместо кириллицы.
Просмотр КП: сессия, а не запрос
Менеджеру важно знать, открыл ли клиент документ. Но перезагрузка и кнопка
«назад» — не новые просмотры, а мессенджеры дёргают ссылку сами, чтобы
построить превью. Сессия здесь — пара (IP, User-Agent) в получасовом окне,
а is_human ставится только по реальному сигналу со страницы:
скроллу или клику.
Ночная задача обезличивает IP и User-Agent у старых просмотров, но не удаляет записи, поэтому счётчики продолжают работать и через год.
В Telegram менеджеру приходит: «КП открыли с двух устройств, 3 раза» — с прямой ссылкой на документ.
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/ отдаёт пустой результат и пишет лог. Каталог, карточки и КП работают. Жёсткой зависимости нет по контракту. |
| Redis | IGNORE_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(), флаш под коротким локом.
Ничего лишнего, всё обосновано
«Написал и уверен» — не доказательство
В репозитории лежит инженерный регламент, по которому идёт каждая сессия работы.
Задача не выполнена, пока make check не вышел с кодом 0.
parler ломает пагинацию
Фильтр по переводимому полю разворачивается в джоин с таблицей переводов и даёт дубли. Лечится translations__<field> + language_code + .distinct() — и это записано в регламент, а не в чью-то память.
N+1 не по недосмотру
Сервисы отдают списки. Любое новое обращение к связанной модели идёт в select_related/prefetch_related базового queryset, а не в цикл сериализации.
Нужен бэкенд, который переживёт третий год эксплуатации?
Я беру проекты целиком: от схемы данных и границ слоёв до Docker-образа, который поднимается на пустом сервере одной командой. Каталоги, B2B-порталы, интеграции, автоматизация того, что менеджеры сегодня делают руками.
Обсудить проект →Похожие задачи в других отраслях
Управленческая отчётность для МойСклад
ОПиУ, движение денег и управленческий баланс из живых данных учётной системы.
смотреть кейс → Финансы · автоматизацияАвтопривязка платежей
Платёж сам привязывается к документу по одному из восемнадцати правил.
смотреть кейс → Медиа · SEOНовостной портал
Карты сайта, RSS, разметка и мгновенные пуши поисковикам собираются сами.
смотреть кейс →