Генератор КП: сбор заявок с сайта и диаграммы из CSV
Чем серьёзнее консалтинговый бизнес, тем больше времени команда тратит не на сам консалтинг, а на бумажную обвязку вокруг него - я заметил это давно. Собрать коммерческое предложение по итогам диагностики, оформить отчёт «Итоги диагностики», не потерять лида с рекламного лендинга, вовремя выслать ему обещанные материалы. Каждый шаг по отдельности занимает минуты, но в сумме это часы рутины в неделю. Особенно меня раздражал сбор заявок с сайта вручную: лид приходит, а дальше его надо не забыть, прогреть и довести до КП. Проект v0-german вырос ровно из этого: внутренний back-office на Next.js, который автоматизирует операционку - от генерации презентаций по транскрипту звонка до приёма и прогрева лидов. Дальше разберу три ключевых блока, которые мы довели до продакшена, и почему каждое техническое решение принималось именно так.
Контекст: что такое v0-german
v0-german — это Next.js-приложение (App Router, React 19, TypeScript), которое работает в связке с отдельным сервисом kp-renderer. Архитектурно мы намеренно разнесли «мозг» и «руки»: основное приложение отвечает за бизнес-логику, генерацию текстов через LLM и хранение данных в Supabase, а kp-renderer — это узкоспециализированный сервис на отдельном VPS, который умеет одно, но хорошо: превращать HTML-шаблоны в пиксельно точные PDF и PNG через Playwright. Такое разделение даёт нам гибкость — рендер тяжёлый, его удобно масштабировать и перезапускать отдельно, не трогая основное приложение.
Центральная фича приложения — генератор в формате wizard из шести шагов. Грубо пайплайн выглядит так:
Шаг 1: данные клиента
Шаг 2: транскрибация звонка (+ теперь CSV диагностики)
Шаг 3: build-kp → structured_kp через GPT-4o
Шаг 4: build-slide-plan → план слайдов
Шаг 5: create-presentation → проксирование в kp-renderer
Шаг 6: готовый PDF/PNG (14 слайдов)
На входе — сырой транскрипт диагностической сессии, на выходе — готовое КП и отчёт «Итоги диагностики», оформленные в фирменном стиле. Менеджеру остаётся проверить и отправить. Звучит просто, но дьявол, как всегда, в деталях рендера и данных.
Проблема №1: диагностика без визуализации
Первая большая задача этого цикла звучала так: на диагностике клиент отвечает на набор вопросов, ответы сводятся в табличку (Excel или CSV), и по этой табличке нужна диаграмма с фиксированными сегментами бизнеса. Эта диаграмма должна вставляться в отчёт «Итоги диагностики» строго после блока «Ключевые проблемы». До этого отчёт был чисто текстовым: проблемы перечислены словами, но у клиента не было наглядной картинки «вот так у вас провисает каждое направление».
Клиент прислал готовый HTML-пример того, как должна выглядеть диаграмма — горизонтальные полосы с процентами по пяти направлениям бизнеса и цветовыми зонами. Передо мной встал классический архитектурный выбор, и я хочу на нём остановиться, потому что он показателен.
Развилка: нативный рендер или Chart.js
Отчёты у нас изначально рендерились двумя способами в разных частях системы — где-то через @react-pdf/renderer, где-то через HTML-шаблоны в kp-renderer. Поэтому вариантов было два:
Вариант A — нативный рендер через <View>. Нарисовать диаграмму прямо в @react-pdf/renderer: пять горизонтальных полос, цветные зоны, процентные лейблы. Плюсы — детерминированно, быстро, без сетевых вызовов, всё в брендовых стилях. Минус — это «своя» диаграмма, которая лишь похожа на присланный HTML, но не идентична ему.
Вариант B — Chart.js через kp-renderer. Взять Chart.js с CDN, отрендерить тот самый HTML в kp-renderer, получить PNG и вставить как изображение. Плюс — пиксельное соответствие тому, что клиент уже видел и одобрил. Минус — лишняя сетевая ходка и зависимость от рендерера.
Клиент выбрал B, и формулировка была показательной: «получим именно то, что я вижу». Это важный продуктовый урок — когда у заказчика уже есть утверждённый визуал, борьба за «чуть более чистую архитектуру» через нативный рендер почти всегда проигрывает борьбе за предсказуемость результата. И решающим фактором оказалось то, что «Итоги диагностики» уже рендерились через kp-renderer пятью HTML-шаблонами с Playwright. То есть Chart.js с CDN там в принципе работал, и присланный HTML вставал почти один в один. Архитектурная стоимость варианта B оказалась близка к нулю.
Решение: парсер с фиксированными сегментами + новый шаблон
Ключевое требование к данным: сегментов всегда ровно пять, и они всегда одни и те же. Даже если в табличке клиента направление «Производство» отсутствует или там стоит ноль — оно всё равно должно быть на диаграмме со значением 0. Это требование бизнеса: единая структура отчёта важнее, чем «показывать только то, что есть». Сравнивать отчёты разных клиентов можно только когда оси совпадают.
Поэтому парсер я построил не «по тому, что нашли в файле», а от жёсткого списка из пяти блоков. Логика один в один повторяла ту, что была зашита в присланном HTML:
// lib/generator/diagnostic-scores.ts
const FIXED_SEGMENTS = [
"Маркетинг",
"Продажи",
"Финансы",
"Команда",
"Производство", // всегда присутствует, 0 если нет данных
] as const
export function parseDiagnosticScores(rows: string[][]) {
const counts = new Map<string, number>()
for (const seg of FIXED_SEGMENTS) counts.set(seg, 0)
for (const row of rows) {
const segment = normalize(row[0])
if (counts.has(segment)) {
counts.set(segment, counts.get(segment)! + countYes(row))
}
}
const max = Math.max(...counts.values(), 1)
return FIXED_SEGMENTS.map((name) => {
const count = counts.get(name)!
return { name, count, percent: Math.round((count / max) * 100) }
})
}Главная идея — инициализировать все пять сегментов нулями ДО чтения файла. Тогда отсутствие данных по направлению естественным образом даёт 0, а не «пропуск строки». Это устраняет целый класс багов, когда у одного клиента диаграмма на пять полос, а у другого — на три, и шаблон ломается.
Дальше — POST-эндпоинт, принимающий файл через multipart и возвращающий готовые scores:
// app/api/generator/parse-diagnostic-csv/route.ts
export async function POST(req: Request) {
const form = await req.formData()
const file = form.get("file") as File
const text = await file.text()
const rows = parseCsv(text) // UTF-8 по умолчанию
const scores = parseDiagnosticScores(rows)
return Response.json({ scores })
}По кодировке я сразу заложил UTF-8 дефолтом — в реальности клиенты выгружают CSV из Google Sheets или делают в Excel «Сохранить как CSV UTF-8», так что это покрывает 99% случаев без лишних настроек.
На стороне kp-renderer появился новый шаблон diagnostic-page-02b-chart.html на Handlebars. Он получает массив diagnostic_scores и рисует те же зоны, стили и datalabels, что были в исходном HTML. Сортировка шаблонов по имени (02b) гарантирует, что страница встаёт ровно после блока «Ключевые проблемы»:
{{#each diagnostic_scores}}
<div class="bar-row">
<span class="bar-label">{{name}}</span>
<div class="bar" style="width: {{percent}}%"></div>
<span class="bar-value">{{count}}</span>
</div>
{{/each}}Отдельный нюанс, который легко упустить с Playwright + Chart.js: рендер диаграммы асинхронный. Если сделать скриншот сразу после загрузки страницы, можно поймать пустой холст. Поэтому в рендерере добавлена явная логика ожидания «chart-ready» — рендер ждёт, пока Chart.js дорисует canvas, и только потом снимает PNG.
Важная деталь про устойчивость: если CSV не загружен — секция просто не появляется в отчёте, без ошибок и пустых заглушек. Загрузчик на шаге транскрибации опциональный, с превью первых строк и процентами. Это правильное поведение для опционального ввода: отсутствие данных не должно ломать основной флоу генерации.
Проблема №2: лиды утекали в ручную обработку
Второй большой блок — воронка с рекламного лендинга. У бизнеса есть посадочная страница на Tilda, где люди оставляют контакты в обмен на «20 файлов» полезных материалов, и попадают на страницу благодарности. Воронку с мессенджерами (Telegram, MAX, VK) клиент уже собрал на SaleBot. Оставалась незакрытая дыра — email. Письмо с обещанной ссылкой нужно было высылать автоматически, а сам факт прихода лида — сохранять, чтобы он не растворялся в почтовом ящике менеджера.
Задача свелась к чистому бэкенду: форма Tilda дёргает webhook → v0-german сохраняет лида в Supabase и шлёт письмо через Brevo → дальше трекаем открытия и клики.
Tilda форма (имя/тел/email/роль/штат...)
│
├─→ Webhook → v0-german API → Supabase (leads)
│ → Brevo email (ссылка на 20 файлов)
└─→ thanks-страница
Я завёл две таблицы в Supabase (Postgres): leads — сами контакты со всеми полями формы, и lead_events — журнал событий по каждому лиду (отправлено, открыто, кликнуто). Разделение на сущность и поток событий — стандартный приём: оно позволяет строить воронку по времени, не размазывая статусы по колонкам основной таблицы.
Отправку писем закрыли через Brevo (бывший Sendinblue) — он уже был в нашей инфраструктуре, бесплатного тарифа на 300 писем в день для лид-магнита хватает с запасом. По дороге всплыл подводный камень: Brevo выкатил новую мажорную версию SDK (v5), API изменился, и первую реализацию lib/email.ts пришлось переписать под новый интерфейс. Классическая история — копируешь рабочий сниппет из соседнего проекта, а там стоит старая версия пакета.
// lib/email.ts (Brevo v5)
import { TransactionalEmailsApi, SendSmtpEmail } from "@getbrevo/brevo"
export async function sendEmail({ to, subject, html }: EmailArgs) {
const api = new TransactionalEmailsApi()
api.setApiKey(/* ключ из env, никаких хардкодов */)
const message = new SendSmtpEmail()
message.to = [{ email: to }]
message.sender = { email: "noreply@<домен>", name: "Команда" }
message.subject = subject
message.htmlContent = html
return api.sendTransacEmail(message)
}Отдельно мы не стали полагаться только на встроенный трекинг Brevo, а сделали self-hosted email-трекинг: пиксель открытия и редирект-обёртку для кликов. Каждый клик по ссылке в письме сначала идёт на наш эндпоинт, который пишет событие в lead_events, и только потом редиректит на целевую страницу. Это даёт полный контроль над данными воронки и не привязывает аналитику к чужой панели.
Поверх базовой механики выросло ещё несколько автоматизаций, которые хорошо иллюстрируют, как точечная фича превращается в систему: day-1 follow-up письмо (напоминание забрать файлы тем, кто не открыл), и еженедельный отчёт по воронке писем, который по пятницам уходит в Telegram. Последнее — про дисциплину: метрика, которую никто не смотрит, бесполезна, а отчёт, который сам приходит в рабочий чат, читают.
Результат
По первой задаче — отчёт «Итоги диагностики» теперь содержит наглядную диаграмму ровно в том виде, который клиент одобрил заранее, и собирается она автоматически из CSV без ручного рисования. Пять фиксированных сегментов гарантируют, что отчёты разных клиентов сопоставимы между собой. Изменения уложились в четыре файла на стороне v0-german (парсер, API-роут, поле в контексте сессии, загрузчик в UI) плюс один HTML-шаблон в kp-renderer. Билд прошёл чисто, эндпоинт /api/generator/parse-diagnostic-csv зарегистрирован, рендерер ответил /health → ok. Деплой обоих сервисов отработал штатно: kp-renderer через перезапуск systemd-сервиса, v0-german — через GitHub Actions с последующим SSH-pull на Dokploy, оба за пару минут.
По второй задаче — лиды с рекламного лендинга больше не теряются. Каждый контакт падает в Supabase, обещанные материалы уходят на почту автоматически, а открытия и клики трекаются на нашей стороне. Воронка стала измеримой от формы до клика в письме, а еженедельный дайджест в Telegram держит её на виду.
Выводы
Первый и главный урок — про выбор между «чистой архитектурой» и «предсказуемым результатом». Когда заказчик уже видел и одобрил конкретный визуал, технически более изящный нативный рендер проигрывает варианту, который даёт пиксельное соответствие. Я выбрал Chart.js через рендерер именно потому, что цена интеграции оказалась близка к нулю (инфраструктура уже была), а выигрыш в предсказуемости — большим. Архитектурные решения нельзя принимать в вакууме: «лучший» способ — это тот, который дешевле всего ложится на то, что уже построено.
Второй урок — про моделирование данных от инвариантов, а не от входных данных. Жёстко зафиксировав пять сегментов и инициализировав их нулями до чтения файла, я устранил целый класс багов с «плавающим» числом полос и сломанными шаблонами. Когда структура отчёта — часть бизнес-требования, она должна быть зашита в код как константа, а не выводиться из того, что прислал клиент. Это и надёжнее, и делает отчёты сопоставимыми.
Третий урок — про разделение ответственности между сервисами. Разнесение «мозга» (v0-german с бизнес-логикой и LLM) и «рук» (kp-renderer с тяжёлым Playwright-рендером) окупилось ровно в тот момент, когда понадобилось добавить новую страницу в PDF: основное приложение вообще не пришлось менять под рендер, content прошёл as-is, а вся работа свелась к новому HTML-шаблону. Слабая связанность — это не абстрактная мантра, это конкретная экономия времени при следующем изменении.
Четвёртый урок — про то, как точечная фича обрастает системой, и почему это нормально. Задача «выслать письмо» закономерно вытянула за собой хранение лидов, журнал событий, self-hosted трекинг, follow-up и еженедельный отчёт. Это не разрастание скоупа ради скоупа — это естественная гравитация полезной автоматизации: как только данные начинают собираться, появляется смысл их измерять, а как только их измеряют, появляется смысл на них реагировать. Главное здесь — не пытаться построить всю систему сразу, а наращивать её слоями, каждый из которых уже приносит пользу. Мелкий подводный камень с мажорным обновлением SDK Brevo лишний раз напомнил: копировать рабочий код из соседнего проекта удобно, но версии зависимостей нужно сверять, иначе экономия минуты оборачивается отладкой переписанного модуля.

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.