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); });