Лист 03 · разбор проекта

← Каталог работ  ·  English

B2B-портал · Next.js App Router

2025

Вентпром

Портал вентиляционного оборудования, где снабженец сам доходит от «нужно 8 000 м³/ч» до спецификации и коммерческого предложения: каталог с инженерными фасетами, подбор приточной установки по секциям, конфигуратор фасонных изделий и рабочий кабинет с заказами и объектами — поверх номенклатуры, которая живёт в 1С.

Роль
единственный разработчик: архитектура, схема БД, расчётный слой, конфигуратор, админка, интеграция с 1С, фронтенд
Стек
Next.js (App Router) · React 19 · TypeScript · PostgreSQL + Prisma · Meilisearch · NextAuth · Zustand · Three.js / React Three Fiber · Telegraf
Зоны
витрина, рабочий кабинет, админ-конфигуратор — три route group в одном приложении
Объём
974 файла TypeScript, 214 974 строки в src/
База
125 моделей Prisma, 29 миграций
Статус
боевой портал: требует Postgres, Meilisearch и доступ к 1С — публичного демо нет, наружу вынесены две самостоятельные части

01 · Задача

Каждая заявка проходит через голову инженера

Производитель вентиляционного оборудования продаёт две принципиально разные вещи. Первая — складская номенклатура: вентиляторы, решётки, крепёж; здесь работает обычный каталог. Вторая — изделия, которых не существует до заказа: приточная установка, собранная из секций под конкретный расход воздуха, и фасонные изделия, нарезанные под размеры конкретного объекта.

Вторую половину до портала считали люди. Снабженец писал письмо или звонил, инженер открывал старый веб-конфигуратор подрядчика, подбирал типоразмер, собирал состав секций, руками дописывал автоматику — датчики, частотники, щит управления — и отправлял ответ. Один цикл занимал от часов до дня, и каждая итерация по размерам начинала его заново.

Второй источник боли — расхождение сайта и 1С. Цены, остатки и сама номенклатура ведутся в учётной системе, а витрина живёт своей жизнью. Пока связь между ними — ручная выгрузка, витрина по определению врёт, и менеджер всё равно перепроверяет каждую позицию.

Третье — знание, которое нигде не записано. Какие датчики нужны для водяного нагревателя, когда щит обязан быть металлическим, почему при увлажнителе скорость в сечении не должна превышать 3,5 м/с — всё это жило в голове инженера и в чужом легаси-скрипте, который нельзя было ни прочитать, ни проверить.

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

02 · Как устроено

Три зоны в одном приложении, разделённые route group

Витрина, кабинет клиента и админка — это три разных продукта с разными макетами, разной навигацией и разными правами. В App Router они живут как три группы маршрутов в одном дереве: (site), (workspace), (admin). Каждая приносит свой layout.tsx, но все три делят один слой данных, одну схему Prisma и одно расчётное ядро. Доступ к админке режется в middleware.ts по роли в токене, до того как отрисуется хоть один компонент.

(site) · витрина

Каталог, подбор, заказ

200 файлов, 100 877 строк. Каталог с фасетами по применению, по производительности и по категориям; мастер подбора в пять шагов; корзина, быстрый заказ, оформление. Отдельные разделы под вентиляторы, нагреватели, шумоглушители, клапаны, ККБ, узлы смешения — у каждого своя карточка параметров.

(workspace) · кабинет

То, что происходит после сделки

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

(admin) · конфигуратор

44 справочника оборудования

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

Каталог разделён на семейство и вариант: ProductFamily хранит описание, документацию и SEO, ProductVariant — конкретный типоразмер с расходом, давлением, мощностью, шумом, габаритами, ценой и остатком. Фасеты фильтруют варианты, а не семейства: инженер ищет «до 65 дБ и от 3 000 м³/ч», а не «серию ВР». Обе таблицы держат ref1C и code1C — это ключи связи с учётной системой.

Фасеты сделаны маршрутами, а не состоянием клиента: /catalog/application/[type], /catalog/capacity/[range], /catalog/equipment/[category], а числовые диапазоны приезжают в searchParams и собираются в один Prisma-where на сервере. Причина простая и не техническая: подборку нужно уметь переслать коллеге ссылкой, а страница должна открываться уже со списком, а не с крутилкой.

03 · Расчётный слой

Формулы вынесены из интерфейса и из базы

src/lib/engineering/ — 25 модулей, 13 227 строк — не знает ни про React, ни про маршруты, ни про сессию. На вход идут числа и состав секций, на выход — числа плюс два массива: warnings[] и errors[]. Рядом src/lib/calculators/, ещё 12 модулей: аэродинамика, психрометрия, климатические данные, нормы, кухонные вытяжки, бассейны, Kvs клапанов.

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

Вторая: чистые функции можно проверить, не поднимая инфраструктуру. Расчёт водяного нагревателя — это вход из семи чисел и выход из четырнадцати; для него не нужны ни Postgres, ни авторизация.

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

Подбор типоразмера: ограничения скорости накапливаются по составу секций src/lib/engineering/size-variant-selector.ts
// Ограничения сняты с логов прежнего конфигуратора и проверены на его же расчётах
const VELOCITY_LIMITS = {
  DEFAULT_MIN: 1.5,          // м/с — ниже теплообменники работают неэффективно
  DEFAULT_MAX: 8.0,          // м/с — предел без дополнительных секций
  WITH_RECUPERATOR: 9.0,
  WITH_HEATER: 5.0,
  WITH_COOLER: 4.0,
  WITH_HUMIDIFIER: 3.5,      // выше — унос капель с поверхности
  OPTIMAL_MIN: 2.5, OPTIMAL_MAX: 3.5,
};

let maxVelocity = VELOCITY_LIMITS.DEFAULT_MAX;
if (hasRecuperator) maxVelocity = Math.min(maxVelocity, VELOCITY_LIMITS.WITH_RECUPERATOR);
if (hasHeater)      maxVelocity = Math.min(maxVelocity, VELOCITY_LIMITS.WITH_HEATER);
if (hasCooler)      maxVelocity = Math.min(maxVelocity, VELOCITY_LIMITS.WITH_COOLER);
if (hasHumidifier)  maxVelocity = Math.min(maxVelocity, VELOCITY_LIMITS.WITH_HUMIDIFIER);

for (const size of sizes) {
  const faceArea = (size.width / 1000) * (size.height / 1000);   // м²
  const maxVel = Math.max(
    airFlowSupply  / 3600 / faceArea,
    airFlowExhaust / 3600 / faceArea,
  );

  if (maxVel > maxVelocity) continue;   // типоразмер мал
  if (maxVel < minVelocity) continue;   // типоразмер велик

  variants.push({
    sizeId: size.id, width: size.width, height: size.height, faceArea,
    isOptimal: maxVel >= VELOCITY_LIMITS.OPTIMAL_MIN
            && maxVel <= VELOCITY_LIMITS.OPTIMAL_MAX,
    warnings,
  });
}

Сокращено: убраны логирование хода расчёта и заполнение параметров воздуха. Смысл фрагмента — правило, которое раньше знал только инженер: каждая добавленная секция опускает потолок скорости, и типоразмер подбирается не под расход, а под самое жёсткое из ограничений. Отсюда же берётся отметка «оптимальный»: попадание в 2,5–3,5 м/с. Клиенту показывается не один вариант, а весь список прошедших отбор — с пометками, почему один лучше другого.

Поправочные коэффициенты: формула остаётся честной, но её можно свести с паспортом src/lib/engineering/section-calculators.ts
export interface HeatExchangerCorrectionFactors {
  power?: number;         // мощность
  airPressure?: number;   // потери давления по воздуху
  waterPressure?: number; // потери давления по воде
  velocity?: number;
  surface?: number;       // запас по поверхности
}

/** Коэффициент не задан — значение возвращается как есть */
function applyCorrection(value: number, factor?: number): number {
  return factor !== undefined && factor !== null ? value * factor : value;
}

// внутри calculateWaterHeater(): сначала физика…
const massFlowAir  = (input.airflow / 3600) * airDensity(input.inletTemp);
const thermalPower = massFlowAir * 1.005 * (input.desiredOutletTemp - input.inletTemp);

// …потом проверки, которые раньше делал человек…
if (waterVelocity < 0.5)
  warnings.push(`Скорость воды ${waterVelocity.toFixed(2)} м/с ниже минимальной — риск замерзания`);
if (velocity > 5.0)
  warnings.push(`Скорость воздуха ${velocity.toFixed(2)} м/с превышает рекомендуемую для нагревателя`);

// …и только в конце — калибровка под конкретную модель из справочника
const cf = input.correctionFactors;
if (cf) {
  correctedPower    = applyCorrection(thermalPower,      cf.power);
  airPressureDrop   = applyCorrection(airPressureDrop,   cf.airPressure);
  waterPressureDrop = applyCorrection(waterPressureDrop, cf.waterPressure);
  surfaceReserve    = applyCorrection(surfaceReserve,    cf.surface);
}

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

04 · Конфигуратор приточной установки

Установка — это упорядоченный список секций, а не карточка товара

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

  1. Подбор типоразмера selectSizeVariants() перебирает типоразмеры серии из базы, считает скорость в живом сечении и оставляет те, что уложились в ограничения. Возвращается список вариантов с пометками «оптимальный» и предупреждениями, а не единственный ответ.
  2. Расчёт секций по цепочке 12 расчётчиков в SectionCalculators: фильтр, роторный и пластинчатый рекуператоры, водяной и электрический нагреватели, водяной и фреоновый охладители, вентилятор, шумоглушитель, секция смешения, заслонка, гибкая вставка. Каждый принимает SectionInput — расход, габарит, температура и влажность на входе — и отдаёт SectionResult с параметрами воздуха на выходе. Психрометрия честная: влагосодержание, энтальпия, точка росы, температура мокрого термометра по формуле Стулла.
  3. Валидация топологии 18 правил в build-validation.ts проверяют не числа, а порядок: максимум один вентилятор на ветку, фильтр обязан стоять перед рекуператором и перед теплообменником, увлажнитель — не где попало, гликолевые рекуператоры — только парой, газовый нагреватель требует пустой секции после себя. Ошибки делятся на блокирующие и предупреждения: первые останавливают расчёт, вторые просто видны рядом с результатом.
  4. Сборка спецификации BOMService добавляет к секциям то, о чём клиент не думает: датчики перепада давления, защиту от замерзания, приводы клапанов, частотники, щит управления. Набор выводится из состава установки — каждый датчик в справочнике привязан к типу секции и к признаку «один на установку» либо «на каждую секцию».
  5. Автоматика по правилам, а не по опыту ahu-configurator-logic.ts отвечает на вопросы, которые раньше решались памятью инженера: материал щита определяется мощностью и фазностью двигателей и суммарной мощностью электронагрева; тип привода клапана — наличием водяного нагрева (тогда нужен возврат пружиной) и рециркуляции; количество заслонок — площадью сечения.
  6. Цена и документ Позиции спецификации связываются с ценами компонентов, результат уходит в PDF — коммерческое предложение, счёт, отчёт о расчёте — и в заказ, который дальше живёт в кабинете и в 1С.

Правила подбора не выдуманы заново. Они восстановлены из легаси-конфигуратора: разобран его клиентский JavaScript и HTML-шаблоны, каждая функция сопоставлена с новым модулем, и результат сведён в таблицу переноса — что перенесено, куда именно и что сознательно не переносилось. Без такого реестра «портирование бизнес-логики» превращается в набор догадок, которые всплывают через полгода на конкретном заказе.

05 · Фасонные изделия

62 типа изделий, описанных параметрами, а не картинками

Вторая половина продукции — воздуховоды и фасонные изделия под размер. В src/lib/duct-3d/ каждый тип описан как TypeScript-интерфейс: набор параметров с теми же буквенными обозначениями, что и на заводском чертеже (A — ширина, B — высота, L — длина, R — радиус изгиба, α — угол), плюс тип соединения на каждом торце: TDC, фланец, шинорейка, заглушка, сетка.

Типов 62: прямые участки круглые и прямоугольные, отводы, переходы концентрические и эксцентрические, тройники и «штаны», крестовины, утки, заглушки, врезки восьми видов, зонты, дефлекторы, ниппели, муфты, фланцы, жироуловители и теплоизоляция для каждой из этих форм.

Превью строится на React Three Fiber прямо из этих параметров: меняется число в поле — пересобирается геометрия. Отдельный слой DimensionLabels (693 строки) рисует размерные линии и выноски, потому что инженеру нужен не красивый рендер, а чертёж, по которому видно, что заказано именно то.

Тот же параметрический набор используется дальше: он идёт в расчёт раскроя и материалоёмкости, в спецификацию и в 3D-упаковку изделий в транспорт — потому что изделие представлено числами, а не файлом модели.

06 · Справочники и массовые операции

Опасные действия по умолчанию ничего не делают

Конфигуратор живёт на справочниках оборудования, а справочники приезжают выгрузками: Excel от поставщика, прайс, обновление из 1С. Отсюда три инструмента в админке, которые выглядят скучно, но экономят больше всего времени: массовые операции по 21 таблице оборудования, сравнение двух импортов с разбивкой «добавлено / удалено / изменено» и построчным списком изменённых полей, и проверка целостности — какие записи остались без цены, без GUID или без связи с ценовым справочником.

Массовая привязка оборудования к ценам: сухой прогон по умолчанию src/app/api/admin/configurator/bulk/route.ts
// Флаг не передали — значит, только посчитать. Ошибиться можно один раз,
// и лучше пусть эта ошибка будет «ничего не произошло».
const dryRun = params.dryRun ?? true;

async function autoLinkByGuid(tables?: string[], dryRun = true) {
  for (const table of tablesToProcess) {
    // записи, у которых GUID из 1С есть, а цены к ним не привязано
    const unlinked = await (prisma as any)[table].findMany({
      where: { guid: { not: null }, componentPriceId: null },
      select: { id: true, guid: true },
    });

    const prices = await prisma.componentPrice.findMany({
      where: { guid: { in: unlinked.map((u) => u.guid) } },
      select: { id: true, guid: true },
    });
    const priceMap = new Map(prices.map((p) => [p.guid, p.id]));

    for (const item of unlinked) {
      const priceId = priceMap.get(item.guid);
      if (!priceId) continue;
      if (!dryRun) await (prisma as any)[table].update({
        where: { id: item.id }, data: { componentPriceId: priceId },
      });
      linkedCount++;                // считается в обоих режимах
    }
    details.push(`${table}: ${linkedCount} записей привязано`);
  }
}

Счётчик увеличивается и при сухом прогоне — поэтому админ сначала видит точный отчёт «в таблице вентиляторов привяжется 412 записей», а уже потом решает, нажимать ли. Привязка идёт по GUID из учётной системы, а не по названию: имена в прайсах пишут как угодно, GUID — единственное, что переживает переименование. Отдельным действием есть привязка по имени, но она вторая по очереди и всегда требует подтверждения.

07 · Синхронизация с 1С

Номенклатура принадлежит учётной системе, портал её отражает

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

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

Запись — наоборот, через вебхуки от 1С: пять типов событий (цена, остаток, изменение товара, удаление, статус заказа), до 5 000 позиций за запрос, валидация полей до первого обращения к базе, авторизация по токену в заголовке.

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

Вебхук от 1С: событие, лимит пачки и обновление по GUID характеристики src/app/api/1c/webhook/product-update/route.ts
interface WebhookPayload {
  event_type: 'price_update' | 'stock_update' | 'product_update'
            | 'product_delete' | 'order_update';
  timestamp: string;
  products: WebhookProduct[];
}

// Валидация до первого запроса к базе: пустая пачка и пачка на 50 000 позиций
// одинаково вредны, и обе отсекаются здесь.
if (!Array.isArray(payload.products) || payload.products.length === 0)
  return { valid: false, error: 'products must be a non-empty array' };
if (payload.products.length > 5000)
  return { valid: false, error: 'Maximum 5000 products per request' };
if (!item.ref_key)
  return { valid: false, error: `Missing required field: ref_key at index ${i}` };

// Цена приходит на характеристику номенклатуры, а не на карточку товара:
// в схеме портала это ProductVariant, найденный по GUID из 1С.
await prisma.productVariant.update({
  where: { ref1C: p.characteristic_ref },
  data:  { price: p.price, priceOld: p.price_old },
});

// Остаток — там же, и признак наличия выводится из количества,
// чтобы «в наличии» и «0 шт» не могли разойтись.
await prisma.productVariant.update({
  where: { ref1C: p.characteristic_ref },
  data:  { stockQty: p.stock_qty, inStock: (p.stock_qty || 0) > 0 },
});

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

08 · Что можно открыть

Две части вынуты из портала и работают отдельно

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

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

09 · Цифры

Что посчитано в исходниках

974

файла TypeScript в src/

214 974

строки кода

206

страниц App Router

154

маршрута API

125

моделей Prisma

44

справочника оборудования в админке

СлойФайловСтрокЧто внутри
Витрина (site)200100 877каталог, фасеты, мастер подбора, корзина, оформление
Админ-конфигуратор (admin)8515 71221 раздел, из них 44 справочника оборудования
Кабинет (workspace)262 968заказы, КП, объекты, монтажи, поддержка, аналитика
API-маршруты156 (154 route.ts)16 262каталог, конфигуратор, вебхуки 1С, телеграм, PDF
Серверные экшены604 743корзина, заказы, расчёты, быстрый заказ
Компоненты28540 351каталог, конфигуратор, 3D, графики, формы
Расчётное ядро lib/engineering2513 227секции, подбор, валидация, спецификация, цена
Калькуляторы lib/calculators122 391аэродинамика, психрометрия, климат, нормы
3D фасонных изделий lib/duct-3d95 34162 типа изделий, меши, размерные линии
Схема данных12 802125 моделей, 15 перечислений, 29 миграций

Что стало быстрее

Подбор приточной установки перестал требовать участия инженера: путь «расход воздуха → список подходящих типоразмеров → состав секций → спецификация с автоматикой → КП» целиком проходит в браузере. Раньше на этом маршруте стояли два человека и переписка.

Быстрый заказ снимает ручной ввод: в поле вставляется список артикулов из письма или бросается файл Excel/CSV — позиции сопоставляются с номенклатурой и попадают в корзину пачкой. Для повторных закупок на объект это разница между «двадцать минут» и «одна вставка».

Что стало проще

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

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

10 · Что осталось за кадром

Ограничения, которые я знаю и не прячу

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