40006-vm/backend/scripts/seed-furniture-demo-data.js
Flatlogic Bot de3ba95d58 123
2026-05-16 19:08:05 +00:00

849 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const axios = require('axios');
const config = require('../src/config');
const API_BASE_URL = process.env.DEMO_DATA_API_URL || 'http://127.0.0.1:3000/api';
const LOGIN_EMAIL = process.env.DEMO_DATA_EMAIL || config.admin_email;
const LOGIN_PASSWORD = process.env.DEMO_DATA_PASSWORD || config.admin_pass;
const LIST_LIMIT = 200;
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
const summary = {
social_channels: { created: 0, updated: 0 },
products: { created: 0, updated: 0 },
case_studies: { created: 0, updated: 0 },
content_briefs: { created: 0, updated: 0 },
content_ideas: { created: 0, updated: 0 },
content_plan_items: { created: 0, updated: 0 },
generated_contents: { created: 0, updated: 0 },
leads: { created: 0, updated: 0 },
lead_activities: { created: 0, updated: 0 },
};
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response) {
console.error('API request failed:', {
method: error.config?.method,
url: error.config?.url,
status: error.response.status,
response: error.response.data,
});
} else {
console.error('Request failed before response:', error.message);
}
throw error;
},
);
function track(resource, action) {
summary[resource][action] += 1;
}
function makeImportHash(parts) {
return ['furniture-demo', ...parts].join(':');
}
function normalize(value) {
return String(value || '')
.trim()
.toLowerCase();
}
function dateOffset(daysOffset, hour, minute = 0) {
const value = new Date();
value.setUTCDate(value.getUTCDate() + daysOffset);
value.setUTCHours(hour, minute, 0, 0);
return value.toISOString();
}
async function login() {
const { data: token } = await api.post('/auth/signin/local', {
email: LOGIN_EMAIL,
password: LOGIN_PASSWORD,
});
api.defaults.headers.common.Authorization = `Bearer ${token}`;
const { data: currentUser } = await api.get('/auth/me');
return currentUser;
}
async function list(resource) {
const rows = [];
let page = 0;
while (page < 20) {
const { data } = await api.get(`/${resource}?limit=${LIST_LIMIT}&page=${page}`);
const batch = Array.isArray(data.rows) ? data.rows : [];
rows.push(...batch);
if (batch.length < LIST_LIMIT) {
break;
}
page += 1;
}
return rows;
}
function findExisting(rows, payload, matchFields = []) {
if (payload.importHash) {
const matchByImportHash = rows.find((item) => item.importHash === payload.importHash);
if (matchByImportHash) {
return matchByImportHash;
}
}
if (!matchFields.length) {
return null;
}
return rows.find((item) =>
matchFields.every((field) => normalize(item[field]) === normalize(payload[field])),
);
}
async function findByImportHashInDatabase(resource, importHash) {
if (!importHash) {
return null;
}
if (!/^[a-z_]+$/.test(resource)) {
throw new Error(`Unsafe resource name: ${resource}`);
}
const escapedImportHash = importHash.replace(/'/g, );
const sql = `SELECT id, "importHash" FROM ${resource} WHERE "importHash" = '${escapedImportHash}' LIMIT 1`;
const { data } = await api.post('/sql', { sql });
return Array.isArray(data.rows) && data.rows.length ? data.rows[0] : null;
}
async function upsert(resource, rows, payload, options = {}) {
const { matchFields = [], labelFields = [] } = options;
let existing = findExisting(rows, payload, matchFields);
const label = labelFields.map((field) => payload[field]).find(Boolean) || payload.importHash || resource;
if (!existing && payload.importHash) {
existing = await findByImportHashInDatabase(resource, payload.importHash);
if (existing && !rows.some((item) => item.id === existing.id)) {
rows.push(existing);
}
}
if (existing) {
await api.put(`/${resource}/${existing.id}`, {
id: existing.id,
data: payload,
});
track(resource, 'updated');
console.log(`↺ Updated ${resource}: ${label}`);
const updatedRow = {
...existing,
...payload,
id: existing.id,
};
const index = rows.findIndex((item) => item.id === existing.id);
if (index >= 0) {
rows[index] = updatedRow;
} else {
rows.push(updatedRow);
}
return updatedRow;
}
const { data: createResponse } = await api.post(`/${resource}`, {
data: payload,
});
let created = createResponse;
if (!created || !created.id) {
created = (payload.importHash && (await findByImportHashInDatabase(resource, payload.importHash))) || null;
if (!created) {
const refreshedRows = await list(resource);
created = findExisting(refreshedRows, payload, matchFields);
rows.splice(0, rows.length, ...refreshedRows);
}
}
if (!created || !created.id) {
throw new Error(`Created ${resource} item could not be located afterwards: ${label}`);
}
track(resource, 'created');
if (!rows.some((item) => item.id === created.id)) {
rows.push(created);
}
console.log(` Created ${resource}: ${label}`);
return created;
}
async function main() {
console.log(`Connecting to ${API_BASE_URL}`);
let currentUser = await login();
const organizationName = currentUser.organization?.name || 'Фабрика мебели на заказ';
const organizationId = currentUser.organization?.id || null;
const ownerUserId = currentUser.id;
console.log(`Signed in as ${currentUser.email} (${organizationName})`);
const [
socialChannels,
products,
caseStudies,
contentBriefs,
contentIdeas,
contentPlanItems,
generatedContents,
leads,
leadActivities,
] = await Promise.all([
list('social_channels'),
list('products'),
list('case_studies'),
list('content_briefs'),
list('content_ideas'),
list('content_plan_items'),
list('generated_contents'),
list('leads'),
list('lead_activities'),
]);
const channels = {};
for (const channelPayload of [
{
key: 'instagram_showroom',
platform: 'instagram',
account_name: 'atelier_forma_msk',
account_url: 'https://instagram.com/atelier_forma_msk',
access_status: 'connected',
notes: `Основная витрина ${organizationName}: кейсы, до/после, рилсы из цеха.`,
is_active: true,
importHash: makeImportHash(['social_channels', 'instagram-showroom']),
},
{
key: 'telegram_channel',
platform: 'telegram',
account_name: '@atelier_forma_design',
account_url: 'https://t.me/atelier_forma_design',
access_status: 'connected',
notes: 'Канал с кейсами, материалами, ответами на частые вопросы и CTA на замер.',
is_active: true,
importHash: makeImportHash(['social_channels', 'telegram-channel']),
},
{
key: 'vk_local',
platform: 'vk',
account_name: 'atelier_forma_custom',
account_url: 'https://vk.com/atelier_forma_custom',
access_status: 'connected',
notes: 'Локальное продвижение, отзывы клиентов и подборки реализованных проектов.',
is_active: true,
importHash: makeImportHash(['social_channels', 'vk-local']),
},
]) {
const savedChannel = await upsert('social_channels', socialChannels, channelPayload, {
matchFields: ['account_name'],
labelFields: ['account_name'],
});
channels[channelPayload.key] = savedChannel.id;
}
const productIds = {};
for (const productPayload of [
{
key: 'premium_kitchens',
category: 'kitchen',
name: 'Премиальные кухни на заказ',
description:
'Кухни под размеры клиента: остров, пеналы до потолка, встроенная техника и продуманная эргономика.',
starting_price: 390000,
materials: 'МДФ эмаль, дубовый шпон, HPL-пластик, кварцевые столешницы.',
features: 'Blum, скрытая подсветка, внутренние органайзеры, проект и монтаж под ключ.',
is_active: true,
importHash: makeImportHash(['products', 'premium-kitchens']),
},
{
key: 'wardrobes_to_ceiling',
category: 'wardrobe',
name: 'Шкафы-купе до потолка',
description:
'Шкафы для прихожих, спален и ниш с точной подгонкой под геометрию помещения.',
starting_price: 210000,
materials: 'ЛДСП Egger, МДФ в эмали, алюминиевые профили, стекло и зеркало.',
features: 'Максимум хранения, антресоли, подсветка полок, скрытые ручки.',
is_active: true,
importHash: makeImportHash(['products', 'wardrobes-to-ceiling']),
},
{
key: 'walk_in_closets',
category: 'closet',
name: 'Гардеробные системы под размер',
description:
'Гардеробные для мастер-спален и отдельных комнат: открытые секции, островные тумбы и зеркала.',
starting_price: 340000,
materials: 'Шпон, ЛДСП премиум-класса, металл, тонированное стекло.',
features: 'Сценарии хранения, секции под обувь и сумки, мягкая подсветка, доводчики.',
is_active: true,
importHash: makeImportHash(['products', 'walk-in-closets']),
},
{
key: 'living_room_systems',
category: 'living_room',
name: 'ТВ-зоны и системы хранения для гостиной',
description:
'Композиции для гостиной с витринами, скрытыми модулями и зонами под технику.',
starting_price: 280000,
materials: 'Шпон ореха, матовые фасады, фрезерованные панели, металл.',
features: 'Скрытая проводка, витрины с подсветкой, комбинация открытого и закрытого хранения.',
is_active: true,
importHash: makeImportHash(['products', 'living-room-systems']),
},
{
key: 'home_offices',
category: 'office',
name: 'Домашние кабинеты и библиотеки',
description:
'Рабочие зоны, библиотеки и мебель для кабинета с эргономикой под ежедневную нагрузку.',
starting_price: 260000,
materials: 'Шпон дуба, эмаль, акустические панели, металл в порошковой окраске.',
features: 'Кабель-менеджмент, столешницы увеличенной глубины, закрытые модули, подсветка.',
is_active: true,
importHash: makeImportHash(['products', 'home-offices']),
},
]) {
const savedProduct = await upsert('products', products, productPayload, {
matchFields: ['name'],
labelFields: ['name'],
});
productIds[productPayload.key] = savedProduct.id;
}
const caseStudyIds = {};
for (const caseStudyPayload of [
{
key: 'kitchen_symbol',
title: 'Матовая кухня с островом для семьи в ЖК «Символ»',
project_type: 'kitchen',
client_name: 'Семья П.',
location: 'Москва, ЖК «Символ»',
budget: 680000,
project_start_at: dateOffset(-95, 8),
project_end_at: dateOffset(-72, 17),
problem:
'Нужно было разместить много хранения и полноценную рабочую поверхность в кухне-гостиной без визуального перегруза.',
solution:
'Сделали матовые фасады в теплом сером цвете, остров с хранением, высокие пеналы и скрытую подсветку рабочей зоны.',
results:
'Клиенты получили кухню с удобной посадкой, местом под технику и аккуратным премиальным видом для фото и видео.',
is_featured: true,
status: 'published',
product: productIds.premium_kitchens,
importHash: makeImportHash(['case_studies', 'kitchen-symbol']),
},
{
key: 'hallway_wardrobe',
title: 'Шкаф-купе до потолка в прихожей с зеркальными фасадами',
project_type: 'wardrobe',
client_name: 'Антон и Мария С.',
location: 'Москва, Хамовники',
budget: 245000,
project_start_at: dateOffset(-68, 9),
project_end_at: dateOffset(-54, 16),
problem:
'Узкая прихожая быстро захламлялась, а обычный шкаф визуально сужал пространство.',
solution:
'Собрали шкаф-купе до потолка со светлыми фасадами, зеркалом, секцией под обувь и антресолью под сезонные вещи.',
results:
'Хранение стало компактным, прихожая визуально расширилась, а клиент отметил удобство ежедневного использования.',
is_featured: true,
status: 'published',
product: productIds.wardrobes_to_ceiling,
importHash: makeImportHash(['case_studies', 'hallway-wardrobe']),
},
{
key: 'master_bedroom_closet',
title: 'Гардеробная 8 м² с островной тумбой в мастер-спальне',
project_type: 'closet',
client_name: 'Екатерина В.',
location: 'Московская область, Истра',
budget: 430000,
project_start_at: dateOffset(-46, 8),
project_end_at: dateOffset(-31, 18),
problem:
'Клиенту нужен был удобный сценарий хранения одежды, обуви и аксессуаров без ощущения тесноты.',
solution:
'Разделили гардеробную на мужскую и женскую зоны, добавили островную тумбу, подсветку и высокие зеркала.',
results:
'Комната стала функциональной и визуально лёгкой, а порядок поддерживается без лишних усилий.',
is_featured: true,
status: 'published',
product: productIds.walk_in_closets,
importHash: makeImportHash(['case_studies', 'master-bedroom-closet']),
},
{
key: 'home_office_library',
title: 'Домашний кабинет с библиотекой и скрытой проводкой',
project_type: 'office',
client_name: 'Илья Н.',
location: 'Москва, Раменки',
budget: 315000,
project_start_at: dateOffset(-28, 9),
project_end_at: dateOffset(-16, 17),
problem:
'Кабинет должен был выглядеть статусно на видеозвонках и одновременно быть удобным для ежедневной работы.',
solution:
'Сделали библиотеку по всей стене, рабочий стол увеличенной глубины, кабель-каналы и акцентную подсветку.',
results:
'Кабинет выглядит цельно и дорого, а рабочее место стало удобнее для длительной концентрации.',
is_featured: false,
status: 'published',
product: productIds.home_offices,
importHash: makeImportHash(['case_studies', 'home-office-library']),
},
]) {
const savedCaseStudy = await upsert('case_studies', caseStudies, caseStudyPayload, {
matchFields: ['title'],
labelFields: ['title'],
});
caseStudyIds[caseStudyPayload.key] = savedCaseStudy.id;
}
const briefIds = {};
for (const briefPayload of [
{
key: 'lead_generation_june',
title: 'Июнь: заявки на кухни для новостроек',
goal: 'lead_generation',
audience: 'new_apartments',
key_offers:
'Бесплатный замер, проект под размеры клиента, монтаж под ключ, подбор материалов под бюджет.',
constraints:
'Не обещать нереалистичные сроки, не использовать агрессивные скидочные формулировки, держать премиальный тон.',
brand_voice: `${organizationName}: спокойный экспертный тон, короткие фразы, акцент на удобстве и процессе.`,
call_to_action: 'Напишите в сообщения, чтобы получить расчёт и записаться на замер.',
hashtags_seed: 'кухниназаказ мебельназаказ мебельподразмер дизайнкухни замербесплатно',
notes:
'Нужны посты и reels, которые помогают собрать входящие заявки от клиентов из новых квартир и домов.',
status: 'active',
owner_user: ownerUserId,
importHash: makeImportHash(['content_briefs', 'lead-generation-june']),
},
{
key: 'behind_scenes_series',
title: 'Серия: как создаётся мебель в цехе',
goal: 'brand_awareness',
audience: 'homeowners',
key_offers:
'Показать производство, качество фурнитуры, этапы сборки и внимание к деталям.',
constraints:
'Без личных данных сотрудников и клиентов, без лишнего технического жаргона.',
brand_voice: 'Тёплый уверенный тон мастеров, ощущение прозрачности и аккуратного ремесла.',
call_to_action: 'Сохраните пост и задайте вопрос по своему проекту в сообщениях.',
hashtags_seed: 'мебельныйцех производство мебели столярка мебельназаказ кейсыпомебели',
notes:
'Подходит для reels и stories: показать процесс, детали, фурнитуру и чистоту производства.',
status: 'active',
owner_user: ownerUserId,
importHash: makeImportHash(['content_briefs', 'behind-scenes-series']),
},
]) {
const savedBrief = await upsert('content_briefs', contentBriefs, briefPayload, {
matchFields: ['title'],
labelFields: ['title'],
});
briefIds[briefPayload.key] = savedBrief.id;
}
const ideaIds = {};
for (const ideaPayload of [
{
key: 'measurement_checklist',
title: 'Чек-лист: что подготовить перед бесплатным замером кухни',
format: 'carousel',
pillar: 'education',
hook: 'Этот чек-лист экономит время и помогает избежать дорогих переделок.',
outline:
'Показать 5 шагов: размеры помещения, техника, розетки, желаемый стиль, сроки ремонта.',
suggested_visuals:
'Минималистичный карусельный дизайн, схема кухни, 1 фото реализованного проекта.',
suggested_hashtags: 'замеркухни кухниназаказ планированиекуxни',
status: 'approved',
brief: briefIds.lead_generation_june,
importHash: makeImportHash(['content_ideas', 'measurement-checklist']),
},
{
key: 'kitchen_reel',
title: 'Reel: кухня с островом — до/после за 25 секунд',
format: 'reel',
pillar: 'portfolio',
hook: 'Из пустой коробки — в кухню, где хочется жить каждый день.',
outline:
'Короткий таймлайн: пустое помещение, монтаж, подсветка, остров, финальный широкий кадр.',
suggested_visuals:
'Вертикальное видео 9:16, монтаж с быстрыми склейками, крупные планы фурнитуры и света.',
suggested_hashtags: 'допосле кухнясостровом мебельназаказ reel',
status: 'approved',
brief: briefIds.behind_scenes_series,
importHash: makeImportHash(['content_ideas', 'kitchen-reel']),
},
{
key: 'facades_faq',
title: 'Пост: как выбрать материал фасадов без переплаты',
format: 'post',
pillar: 'faq',
hook: 'Разница в фасадах — не только во внешнем виде, но и в сценарии использования.',
outline:
'Сравнить МДФ в эмали, шпон и HPL по внешнему виду, стойкости, стоимости и уходу.',
suggested_visuals:
'Карточка-сравнение материалов и фото реальных фасадов крупным планом.',
suggested_hashtags: 'фасадыкухни мдфэмаль шпон hpl мебельсоветы',
status: 'approved',
brief: briefIds.lead_generation_june,
importHash: makeImportHash(['content_ideas', 'facades-faq']),
},
{
key: 'wardrobe_stories',
title: 'Stories: 3 причины выбрать шкаф до потолка',
format: 'story',
pillar: 'faq',
hook: 'Когда хранение работает лучше, чем кажется на первый взгляд.',
outline:
'Показать выгоду по объёму хранения, визуальной чистоте и удобству уборки.',
suggested_visuals:
'Три коротких вертикальных кадра: общий план, антресоль, внутреннее наполнение.',
suggested_hashtags: 'шкафкупе хранениевквартире мебельдляприхожей',
status: 'approved',
brief: briefIds.lead_generation_june,
importHash: makeImportHash(['content_ideas', 'wardrobe-stories']),
},
]) {
const savedIdea = await upsert('content_ideas', contentIdeas, ideaPayload, {
matchFields: ['title'],
labelFields: ['title'],
});
ideaIds[ideaPayload.key] = savedIdea.id;
}
const planItemIds = {};
for (const planItemPayload of [
{
key: 'checklist_carousel',
content_type: 'carousel',
scheduled_at: dateOffset(1, 10),
publish_window_end_at: dateOffset(1, 18),
status: 'ready',
title: 'Карусель: что подготовить к замеру кухни',
notes: 'Нужен сильный CTA в последнем слайде: заявка на бесплатный замер.',
channel: channels.telegram_channel,
idea: ideaIds.measurement_checklist,
case_study: caseStudyIds.kitchen_symbol,
importHash: makeImportHash(['content_plan_items', 'checklist-carousel']),
},
{
key: 'kitchen_reel_publish',
content_type: 'reel',
scheduled_at: dateOffset(3, 18, 30),
publish_window_end_at: dateOffset(3, 21),
status: 'scheduled',
title: 'Reel: трансформация кухни с островом',
notes: 'Добавить подписи по этапам монтажа и вынести в первые 2 секунды финальный кадр.',
channel: channels.instagram_showroom,
idea: ideaIds.kitchen_reel,
case_study: caseStudyIds.kitchen_symbol,
importHash: makeImportHash(['content_plan_items', 'kitchen-reel-publish']),
},
{
key: 'facades_post',
content_type: 'post',
scheduled_at: dateOffset(5, 12),
publish_window_end_at: dateOffset(5, 19),
status: 'planned',
title: 'Пост: как выбрать фасады без переплаты',
notes: 'Сделать в конце короткий блок “что выбрать под ваш сценарий использования”.',
channel: channels.vk_local,
idea: ideaIds.facades_faq,
case_study: caseStudyIds.kitchen_symbol,
importHash: makeImportHash(['content_plan_items', 'facades-post']),
},
{
key: 'wardrobe_story_pack',
content_type: 'story',
scheduled_at: dateOffset(7, 17),
publish_window_end_at: dateOffset(7, 21),
status: 'drafting',
title: 'Stories: 3 мифа о шкафах до потолка',
notes: 'Финальный сторис с вопросом “рассчитать ваш шкаф?”.',
channel: channels.instagram_showroom,
idea: ideaIds.wardrobe_stories,
case_study: caseStudyIds.hallway_wardrobe,
importHash: makeImportHash(['content_plan_items', 'wardrobe-story-pack']),
},
]) {
const savedPlanItem = await upsert('content_plan_items', contentPlanItems, planItemPayload, {
matchFields: ['title'],
labelFields: ['title'],
});
planItemIds[planItemPayload.key] = savedPlanItem.id;
}
for (const generatedContentPayload of [
{
kind: 'post_caption',
language: 'ru',
prompt:
'Сделай продающий пост о подготовке к замеру кухни на заказ для аудитории новостроек в Москве.',
output_text: [
'Перед замером кухни важно подготовить не только размеры помещения, но и сценарий жизни семьи.',
'Сохраните этот чек-лист: техника, розетки, любимые материалы, сроки ремонта и желаемый стиль.',
'Чем точнее вводные, тем быстрее мы соберём проект без лишних переделок и сюрпризов по бюджету.',
'Напишите в сообщения — подскажем, что лучше подготовить именно для вашей кухни.',
].join('\n\n'),
hashtags: '#кухниназаказ #замеркухни #дизайнкухни #мебельподразмер #кухнямечты',
status: 'approved',
generated_at: dateOffset(-1, 9),
brief: briefIds.lead_generation_june,
plan_item: planItemIds.checklist_carousel,
importHash: makeImportHash(['generated_contents', 'caption-checklist']),
},
{
kind: 'reel_script',
language: 'ru',
prompt: 'Сделай короткий сценарий reels про кухню с островом: до/после, монтаж, детали, CTA.',
output_text: [
'Сцена 1: пустое помещение — текст на экране “Когда кухня ещё только в планах”.',
'Сцена 2: монтаж пеналов и острова — “Работаем под размеры помещения, а не по шаблону”.',
'Сцена 3: крупные планы фурнитуры и подсветки — “Детали, которые ощущаются каждый день”.',
'Сцена 4: финальный общий кадр — “Хотите такой же проект? Напишите в сообщения”.',
].join('\n\n'),
hashtags: '#reelмебель #кухнясостровом #допосле #мебельназаказ',
status: 'approved',
generated_at: dateOffset(-1, 14),
brief: briefIds.behind_scenes_series,
plan_item: planItemIds.kitchen_reel_publish,
importHash: makeImportHash(['generated_contents', 'reel-kitchen']),
},
{
kind: 'hashtags',
language: 'ru',
prompt: 'Подбери хэштеги для поста о выборе фасадов кухни без переплаты.',
output_text:
'Подборка хэштегов для поста о фасадах кухни: сочетание поисковых, локальных и экспертных тегов.',
hashtags:
'#фасадыкухни #мдфэмаль #шпон #hpl #кухниназаказ #мебельсоветы #ремонтквартиры #дизайнинтерьера',
status: 'draft',
generated_at: dateOffset(-2, 11),
brief: briefIds.lead_generation_june,
plan_item: planItemIds.facades_post,
importHash: makeImportHash(['generated_contents', 'hashtags-facades']),
},
{
kind: 'dm_reply',
language: 'ru',
prompt: 'Сделай короткий ответ на входящий лид, который спрашивает стоимость шкафа до потолка.',
output_text: [
'Здравствуйте! Стоимость шкафа зависит от размеров, наполнения и материалов фасадов.',
'Если отправите примерные размеры или фото ниши, мы быстро дадим ориентир и предложим удобный вариант под ваш бюджет.',
].join('\n\n'),
hashtags: '#ответлиду #продаживдирект',
status: 'approved',
generated_at: dateOffset(-3, 16),
brief: briefIds.lead_generation_june,
plan_item: planItemIds.wardrobe_story_pack,
importHash: makeImportHash(['generated_contents', 'dm-reply-wardrobe']),
},
]) {
await upsert('generated_contents', generatedContents, generatedContentPayload, {
matchFields: ['importHash'],
labelFields: ['kind', 'importHash'],
});
}
const leadIds = {};
for (const leadPayload of [
{
key: 'anna_kitchen',
source_platform: 'instagram',
source_detail: 'Входящий после reels про кухню с островом',
full_name: 'Анна Петрова',
phone: '+7 999 111-22-33',
email: 'anna.pet.demo@example.com',
messenger_handle: '@anna_home_project',
city: 'Москва',
interest_category: 'kitchen',
estimated_budget: 650000,
requested_at: dateOffset(-1, 8, 30),
next_follow_up_at: dateOffset(1, 12),
status: 'new',
notes:
'Квартира в новостройке, нужна кухня с островом и местом под встроенную технику. Просит ориентир по срокам.',
assigned_to: ownerUserId,
importHash: makeImportHash(['leads', 'anna-kitchen']),
},
{
key: 'ilya_wardrobe',
source_platform: 'website',
source_detail: 'Форма заявки на странице шкафов-купе',
full_name: 'Илья Смирнов',
phone: '+7 999 222-33-44',
email: 'ilya.wardrobe.demo@example.com',
messenger_handle: '@ilya_flat',
city: 'Химки',
interest_category: 'wardrobe',
estimated_budget: 230000,
requested_at: dateOffset(-2, 13, 10),
next_follow_up_at: dateOffset(1, 15),
status: 'contacted',
notes:
'Интересует шкаф-купе до потолка в прихожую. Уже отправил размеры ниши и хочет понять разницу по материалам.',
assigned_to: ownerUserId,
importHash: makeImportHash(['leads', 'ilya-wardrobe']),
},
{
key: 'ekaterina_closet',
source_platform: 'telegram',
source_detail: 'Личный диалог после кейса про гардеробную',
full_name: 'Екатерина Волкова',
phone: '+7 999 333-44-55',
email: 'ekaterina.closet.demo@example.com',
messenger_handle: '@ekaterina_style_home',
city: 'Истра',
interest_category: 'closet',
estimated_budget: 420000,
requested_at: dateOffset(-4, 10),
next_follow_up_at: dateOffset(2, 11),
status: 'proposal_sent',
notes:
'Запрос на гардеробную в мастер-спальне. Коммерческое предложение отправлено, ждём согласование по наполнению.',
assigned_to: ownerUserId,
importHash: makeImportHash(['leads', 'ekaterina-closet']),
},
{
key: 'olga_office',
source_platform: 'vk',
source_detail: 'Сообщение после поста про домашние кабинеты',
full_name: 'Ольга Миронова',
phone: '+7 999 444-55-66',
email: 'olga.office.demo@example.com',
messenger_handle: '@olga_remote_work',
city: 'Москва',
interest_category: 'office',
estimated_budget: 310000,
requested_at: dateOffset(-5, 9, 40),
next_follow_up_at: dateOffset(3, 10),
status: 'qualified',
notes:
'Нужен кабинет с библиотекой и скрытой проводкой. Клиент готов к замеру после согласования сроков ремонта.',
assigned_to: ownerUserId,
importHash: makeImportHash(['leads', 'olga-office']),
},
]) {
const savedLead = await upsert('leads', leads, leadPayload, {
matchFields: ['email'],
labelFields: ['full_name'],
});
leadIds[leadPayload.key] = savedLead.id;
}
for (const leadActivityPayload of [
{
activity_type: 'task',
occurred_at: null,
due_at: dateOffset(1, 12),
outcome: 'planned',
summary: 'Связаться с Анной и уточнить размеры кухни',
details:
'Нужно запросить план квартиры, список техники и желаемые сроки установки, затем назначить бесплатный замер.',
lead: leadIds.anna_kitchen,
author_user: ownerUserId,
organizations: organizationId,
importHash: makeImportHash(['lead_activities', 'anna-follow-up']),
},
{
activity_type: 'message',
occurred_at: dateOffset(0, 10, 15),
due_at: null,
outcome: 'done',
summary: 'Отправлены варианты наполнения для шкафа Илье',
details:
'Клиенту отправлены 2 варианта наполнения и пояснение по разнице между ЛДСП и МДФ в эмали.',
lead: leadIds.ilya_wardrobe,
author_user: ownerUserId,
organizations: organizationId,
importHash: makeImportHash(['lead_activities', 'ilya-options-sent']),
},
{
activity_type: 'meeting',
occurred_at: null,
due_at: dateOffset(2, 11),
outcome: 'planned',
summary: 'Созвон по КП с Екатериной',
details:
'Подготовить 2 сценария наполнения гардеробной и пройтись по бюджету перед финальным подтверждением.',
lead: leadIds.ekaterina_closet,
author_user: ownerUserId,
organizations: organizationId,
importHash: makeImportHash(['lead_activities', 'ekaterina-proposal-call']),
},
{
activity_type: 'call',
occurred_at: dateOffset(-1, 17, 20),
due_at: null,
outcome: 'done',
summary: 'Первичный звонок по кабинету для Ольги',
details:
'Обсудили задачи по хранению документов, видеозвонкам и скрытой проводке. Следующий шаг — замер после готовности стен.',
lead: leadIds.olga_office,
author_user: ownerUserId,
organizations: organizationId,
importHash: makeImportHash(['lead_activities', 'olga-intro-call']),
},
]) {
await upsert('lead_activities', leadActivities, leadActivityPayload, {
matchFields: ['importHash'],
labelFields: ['summary'],
});
}
console.log('\nDemo data sync complete.');
Object.entries(summary).forEach(([resource, stats]) => {
console.log(`- ${resource}: created ${stats.created}, updated ${stats.updated}`);
});
}
main().catch((error) => {
console.error('Furniture demo data seeding failed.');
console.error(error.stack || error.message || error);
process.exit(1);
});