B2B-портал · Next.js App Router
2025
Портал вентиляционного оборудования, где снабженец сам доходит от «нужно 8 000 м³/ч» до спецификации и коммерческого предложения: каталог с инженерными фасетами, подбор приточной установки по секциям, конфигуратор фасонных изделий и рабочий кабинет с заказами и объектами — поверх номенклатуры, которая живёт в 1С.
src/01 · Задача
Производитель вентиляционного оборудования продаёт две принципиально разные вещи. Первая — складская номенклатура: вентиляторы, решётки, крепёж; здесь работает обычный каталог. Вторая — изделия, которых не существует до заказа: приточная установка, собранная из секций под конкретный расход воздуха, и фасонные изделия, нарезанные под размеры конкретного объекта.
Вторую половину до портала считали люди. Снабженец писал письмо или звонил, инженер открывал старый веб-конфигуратор подрядчика, подбирал типоразмер, собирал состав секций, руками дописывал автоматику — датчики, частотники, щит управления — и отправлял ответ. Один цикл занимал от часов до дня, и каждая итерация по размерам начинала его заново.
Второй источник боли — расхождение сайта и 1С. Цены, остатки и сама номенклатура ведутся в учётной системе, а витрина живёт своей жизнью. Пока связь между ними — ручная выгрузка, витрина по определению врёт, и менеджер всё равно перепроверяет каждую позицию.
Третье — знание, которое нигде не записано. Какие датчики нужны для водяного нагревателя, когда щит обязан быть металлическим, почему при увлажнителе скорость в сечении не должна превышать 3,5 м/с — всё это жило в голове инженера и в чужом легаси-скрипте, который нельзя было ни прочитать, ни проверить.
Портал должен был перевести эти три вещи в код: правила подбора, связь с учётной системой и путь клиента от расхода воздуха до документа — без посредника.
02 · Как устроено
Витрина, кабинет клиента и админка — это три разных продукта с разными макетами,
разной навигацией и разными правами. В App Router они живут как три группы маршрутов
в одном дереве: (site), (workspace), (admin).
Каждая приносит свой layout.tsx, но все три делят один слой данных,
одну схему Prisma и одно расчётное ядро. Доступ к админке режется в
middleware.ts по роли в токене, до того как отрисуется хоть один компонент.
(site) · витрина
200 файлов, 100 877 строк. Каталог с фасетами по применению, по производительности и по категориям; мастер подбора в пять шагов; корзина, быстрый заказ, оформление. Отдельные разделы под вентиляторы, нагреватели, шумоглушители, клапаны, ККБ, узлы смешения — у каждого своя карточка параметров.
(workspace) · кабинет
Заказы и их статусы, коммерческие предложения, объекты клиента, монтажи, служба поддержки, документы, аналитика закупок, шаблоны повторных заказов. Именно эта зона превращает разовую покупку в историю: следующий заказ на тот же объект собирается из прошлого, а не с нуля.
(admin) · конфигуратор
Вентиляторы, фильтры и фильтровальные вставки, водяные и электрические нагреватели, охладители, испарители, рекуператоры трёх типов, заслонки, гибкие вставки, шумоглушители, увлажнители, узлы смешения, приводы клапанов, частотники, датчики, щиты управления, типоразмеры и серии — плюс цены, массовые операции, сравнение импортов и проверка целостности связей.
Каталог разделён на семейство и вариант: 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, ни авторизация.
Третья: расчёт нужно уметь калибровать под реальное железо. Эмпирическая формула потерь давления даёт приближение, а у конкретного теплообменника из справочника оно другое. Поэтому каждый расчёт теплообменного оборудования принимает набор поправочных коэффициентов, которые хранятся рядом с записью в базе. По умолчанию все равны единице — формула работает и без калибровки.
// Ограничения сняты с логов прежнего конфигуратора и проверены на его же расчётах 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 м/с. Клиенту показывается не один вариант, а весь список прошедших отбор — с пометками, почему один лучше другого.
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 · Конфигуратор приточной установки
Приточная установка собирается из секций, стоящих в корпусе одна за другой: заслонка, фильтр, рекуператор, нагреватель, вентилятор, шумоглушитель. У неё две ветки — приток и вытяжка, — и порядок секций имеет физический смысл: воздух после рекуператора приходит в нагреватель уже другим. Поэтому конфигуратор устроен как конвейер, где выход одной секции становится входом следующей.
selectSizeVariants() перебирает типоразмеры серии из базы, считает скорость
в живом сечении и оставляет те, что уложились в ограничения. Возвращается список
вариантов с пометками «оптимальный» и предупреждениями, а не единственный ответ.
SectionCalculators: фильтр, роторный и пластинчатый
рекуператоры, водяной и электрический нагреватели, водяной и фреоновый охладители,
вентилятор, шумоглушитель, секция смешения, заслонка, гибкая вставка. Каждый принимает
SectionInput — расход, габарит, температура и влажность на входе — и отдаёт
SectionResult с параметрами воздуха на выходе. Психрометрия честная:
влагосодержание, энтальпия, точка росы, температура мокрого термометра по формуле Стулла.
build-validation.ts проверяют не числа, а порядок:
максимум один вентилятор на ветку, фильтр обязан стоять перед рекуператором и перед
теплообменником, увлажнитель — не где попало, гликолевые рекуператоры — только парой,
газовый нагреватель требует пустой секции после себя. Ошибки делятся на блокирующие
и предупреждения: первые останавливают расчёт, вторые просто видны рядом с результатом.
BOMService добавляет к секциям то, о чём клиент не думает: датчики
перепада давления, защиту от замерзания, приводы клапанов, частотники, щит управления.
Набор выводится из состава установки — каждый датчик в справочнике привязан к типу
секции и к признаку «один на установку» либо «на каждую секцию».
ahu-configurator-logic.ts отвечает на вопросы, которые раньше решались
памятью инженера: материал щита определяется мощностью и фазностью двигателей и
суммарной мощностью электронагрева; тип привода клапана — наличием водяного нагрева
(тогда нужен возврат пружиной) и рециркуляции; количество заслонок — площадью сечения.
Правила подбора не выдуманы заново. Они восстановлены из легаси-конфигуратора: разобран его клиентский JavaScript и HTML-шаблоны, каждая функция сопоставлена с новым модулем, и результат сведён в таблицу переноса — что перенесено, куда именно и что сознательно не переносилось. Без такого реестра «портирование бизнес-логики» превращается в набор догадок, которые всплывают через полгода на конкретном заказе.
05 · Фасонные изделия
Вторая половина продукции — воздуховоды и фасонные изделия под размер.
В src/lib/duct-3d/ каждый тип описан как TypeScript-интерфейс:
набор параметров с теми же буквенными обозначениями, что и на заводском чертеже
(A — ширина, B — высота, L — длина,
R — радиус изгиба, α — угол), плюс тип соединения
на каждом торце: TDC, фланец, шинорейка, заглушка, сетка.
Типов 62: прямые участки круглые и прямоугольные, отводы, переходы концентрические и эксцентрические, тройники и «штаны», крестовины, утки, заглушки, врезки восьми видов, зонты, дефлекторы, ниппели, муфты, фланцы, жироуловители и теплоизоляция для каждой из этих форм.
Превью строится на React Three Fiber прямо из этих параметров: меняется число
в поле — пересобирается геометрия. Отдельный слой DimensionLabels
(693 строки) рисует размерные линии и выноски, потому что инженеру нужен не красивый
рендер, а чертёж, по которому видно, что заказано именно то.
Тот же параметрический набор используется дальше: он идёт в расчёт раскроя и материалоёмкости, в спецификацию и в 3D-упаковку изделий в транспорт — потому что изделие представлено числами, а не файлом модели.
06 · Справочники и массовые операции
Конфигуратор живёт на справочниках оборудования, а справочники приезжают выгрузками: Excel от поставщика, прайс, обновление из 1С. Отсюда три инструмента в админке, которые выглядят скучно, но экономят больше всего времени: массовые операции по 21 таблице оборудования, сравнение двух импортов с разбивкой «добавлено / удалено / изменено» и построчным списком изменённых полей, и проверка целостности — какие записи остались без цены, без GUID или без связи с ценовым справочником.
// Флаг не передали — значит, только посчитать. Ошибиться можно один раз, // и лучше пусть эта ошибка будет «ничего не произошло». 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С
Здесь важнее всего было решить, кто хозяин данных. Ответ: 1С. Портал не заводит товары, не назначает им коды и не редактирует цены — он держит зеркало и подписывается на изменения.
Чтение идёт через OData: сначала иерархия папок (папки с признаком «это группа»
разворачиваются в категории и подкатегории), затем номенклатура, привязанная
к категориям через Parent_Key. Позиции, у которых родителя не нашлось,
не теряются — они падают в отдельную категорию, где их видно и можно разобрать руками.
Запись — наоборот, через вебхуки от 1С: пять типов событий (цена, остаток, изменение товара, удаление, статус заказа), до 5 000 позиций за запрос, валидация полей до первого обращения к базе, авторизация по токену в заголовке.
Ключевая деталь — уровень, на который приезжает цена. В 1С цена
и остаток относятся не к товару, а к его характеристике, то есть к конкретному
типоразмеру. В схеме портала это ProductVariant, и вебхук обновляет
именно его — по GUID характеристики. Смена статуса заказа тем же путём доезжает
до пользователя уведомлением в телеграм-боте.
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С не запускается — показать его ссылкой нельзя. Но расчётный слой писался так, чтобы не зависеть от инфраструктуры, и это дало прямое следствие: два куска отделяются от портала и живут самостоятельно. Ниже — живые страницы, не запись экрана.
Живое демо · без сервера
Аэродинамика сети, подбор сечения воздуховода, психрометрика, теплопотери, акустика, дымоудаление, аспирация, бассейны, кухонные вытяжки, Kvs клапанов, климатические данные, подбор аналогов, спецификация. Те же расчётные ядра, что в портале, собранные в статический сайт: вся математика считается в браузере, ни базы, ни авторизации, ни серверных вызовов.
Открыть витрину инструментов↗Живое демо · 3D
Спецификация на входе — раскладка по кузову на выходе. Учитывается вложение изделий друг в друга, вес и порядок укладки, устойчивость круглых сечений, вылет фланцев TDC и шинореек. Несколько стратегий укладки считаются параллельно, лучшая по заполнению показывается в 3D с реальными координатами и поворотами.
Открыть 3D-упаковку↗Обе страницы публикуются из этого же проекта и могут быть ещё не развёрнуты в момент, когда вы это читаете.
09 · Цифры
файла TypeScript в src/
строки кода
страниц App Router
маршрута API
моделей Prisma
справочника оборудования в админке
| Слой | Файлов | Строк | Что внутри |
|---|---|---|---|
Витрина (site) | 200 | 100 877 | каталог, фасеты, мастер подбора, корзина, оформление |
Админ-конфигуратор (admin) | 85 | 15 712 | 21 раздел, из них 44 справочника оборудования |
Кабинет (workspace) | 26 | 2 968 | заказы, КП, объекты, монтажи, поддержка, аналитика |
| API-маршруты | 156 (154 route.ts) | 16 262 | каталог, конфигуратор, вебхуки 1С, телеграм, PDF |
| Серверные экшены | 60 | 4 743 | корзина, заказы, расчёты, быстрый заказ |
| Компоненты | 285 | 40 351 | каталог, конфигуратор, 3D, графики, формы |
Расчётное ядро lib/engineering | 25 | 13 227 | секции, подбор, валидация, спецификация, цена |
Калькуляторы lib/calculators | 12 | 2 391 | аэродинамика, психрометрия, климат, нормы |
3D фасонных изделий lib/duct-3d | 9 | 5 341 | 62 типа изделий, меши, размерные линии |
| Схема данных | 1 | 2 802 | 125 моделей, 15 перечислений, 29 миграций |
Подбор приточной установки перестал требовать участия инженера: путь «расход воздуха → список подходящих типоразмеров → состав секций → спецификация с автоматикой → КП» целиком проходит в браузере. Раньше на этом маршруте стояли два человека и переписка.
Быстрый заказ снимает ручной ввод: в поле вставляется список артикулов из письма или бросается файл Excel/CSV — позиции сопоставляются с номенклатурой и попадают в корзину пачкой. Для повторных закупок на объект это разница между «двадцать минут» и «одна вставка».
Правила подбора перестали быть устным знанием. 18 проверок топологии и логика выбора автоматики лежат в двух модулях, их можно прочитать, обсудить и изменить — вместо того чтобы восстанавливать по памяти инженера или по чужому скрипту.
Обновление справочников перестало быть рискованным: сравнение двух импортов показывает, что именно изменилось, до применения, а массовые операции по умолчанию работают в режиме подсчёта.
10 · Что осталось за кадром
lib/engineering приходится четыре файла юнит-тестов общим объёмом
238 строк, и ни один не проверяет расчёт секций. Основное покрытие — девять
сценариев Playwright, которые ходят по интерфейсу. Правильный шаг здесь —
табличные тесты по эталонным расчётам легаси-конфигуратора: исходные данные и
ожидаемый результат уже есть в его выгрузках, они просто не превращены в фикстуры.
sync-scheduler.ts —
заготовка на 26 строк; регулярный обмен запускается снаружи, скриптом по расписанию
операционной системы. Это работает, но означает, что состояние обмена живёт не в
приложении: мониторинг синхронизации в админке показывает результат, а не очередь.
product_update
возвращает счётчик и не пересобирает карточку — полный ресинк делается отдельным
скриптом. Обновление цены по коду товара (без GUID характеристики) не реализовано:
в текущей схеме цена всегда принадлежит типоразмеру, и обрабатывать «цену на семейство»
было бы угадыванием.
as any — там, где нужен динамический
доступ к модели по имени таблицы или сложный include. Это осознанный
размен на скорость, но он снимает ровно ту защиту, ради которой брался TypeScript,
и в этих местах ошибку поймает только тест.
params и
searchParams пройдены по всем маршрутам, но в дереве остаются страницы,
которые с тех пор не открывали руками, — их поведение подтверждено только сборкой
и типами.
Самая честная оценка проекта такая: инженерная часть — подбор, валидация, спецификация, обмен с 1С — сделана и работает; периметр вокруг неё (тесты, единый дизайн, наблюдаемость обмена) отстаёт от неё на шаг. Для портала, который заменяет переписку с менеджером, это правильный порядок приоритетов, но он не бесконечный: при следующем росте справочников первым отвалится именно периметр.