Lead Radar: исходный код

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuriandClaude Opus 5.5 committed 2026-10-25 23:11:04 +04:00
commit 9749da5ae7
201 files changed
+97685

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
-- Удаляет ВСЕ компании со всеми данными и аккаунтами их участников. Необратимо — сначала сделайте дамп.
-- Остаются: суперадмины, общая база объявлений и её номера, настройки парсера и биллинга.
BEGIN;
CREATE TEMP TABLE c ON COMMIT DROP AS SELECT id FROM companies;
CREATE TEMP TABLE u ON COMMIT DROP AS SELECT id FROM users WHERE company_id IN (SELECT id FROM c);
CREATE TEMP TABLE l ON COMMIT DROP AS SELECT id FROM leads WHERE company_id IN (SELECT id FROM c);
CREATE TEMP TABLE cl ON COMMIT DROP AS SELECT id FROM clients WHERE company_id IN (SELECT id FROM c);
DELETE FROM payments WHERE invoice_id IN (SELECT id FROM invoices WHERE company_id IN (SELECT id FROM c));
DELETE FROM invoices WHERE company_id IN (SELECT id FROM c);
DELETE FROM payouts WHERE company_id IN (SELECT id FROM c);
DELETE FROM pay_adjustments WHERE company_id IN (SELECT id FROM c);
DELETE FROM viewings WHERE company_id IN (SELECT id FROM c);
DELETE FROM client_leads WHERE client_id IN (SELECT id FROM cl) OR lead_id IN (SELECT id FROM l);
DELETE FROM client_pages WHERE company_id IN (SELECT id FROM c);
DELETE FROM client_photos WHERE company_id IN (SELECT id FROM c);
DELETE FROM intake_sessions WHERE client_id IN (SELECT id FROM cl) OR link_token IN (SELECT token FROM intake_links WHERE company_id IN (SELECT id FROM c));
DELETE FROM intake_links WHERE company_id IN (SELECT id FROM c);
DELETE FROM deals WHERE company_id IN (SELECT id FROM c);
DELETE FROM events WHERE company_id IN (SELECT id FROM c);
DELETE FROM lead_notifications WHERE lead_id IN (SELECT id FROM l) OR user_id IN (SELECT id FROM u);
DELETE FROM outbox WHERE lead_id IN (SELECT id FROM l) OR user_id IN (SELECT id FROM u);
DELETE FROM leads WHERE id IN (SELECT id FROM l);
DELETE FROM clients WHERE id IN (SELECT id FROM cl);
DELETE FROM filters WHERE company_id IN (SELECT id FROM c);
DELETE FROM blacklist WHERE company_id IN (SELECT id FROM c);
DELETE FROM listing_phones WHERE company_id IN (SELECT id FROM c) OR user_id IN (SELECT id FROM u);
DELETE FROM invites WHERE company_id IN (SELECT id FROM c);
DELETE FROM pipeline_statuses WHERE company_id IN (SELECT id FROM c);
DELETE FROM reject_reasons WHERE company_id IN (SELECT id FROM c);
DELETE FROM admin_messages WHERE company_id IN (SELECT id FROM c) OR user_id IN (SELECT id FROM u);
DELETE FROM support_messages WHERE ticket_id IN (SELECT id FROM support_tickets WHERE user_id IN (SELECT id FROM u));
UPDATE support_messages SET author_id = NULL WHERE author_id IN (SELECT id FROM u);
DELETE FROM support_tickets WHERE user_id IN (SELECT id FROM u);
DELETE FROM email_tokens WHERE user_id IN (SELECT id FROM u);
DELETE FROM sessions WHERE user_id IN (SELECT id FROM u);
UPDATE sessions SET acting_company_id = NULL WHERE acting_company_id IN (SELECT id FROM c);
DELETE FROM users WHERE id IN (SELECT id FROM u);
DELETE FROM companies WHERE id IN (SELECT id FROM c);
COMMIT;
SELECT (SELECT count(*) FROM companies) AS companies, (SELECT count(*) FROM users) AS users,
(SELECT count(*) FROM leads) AS leads, (SELECT count(*) FROM listings) AS listings;
+52
View File
@@ -0,0 +1,52 @@
-- Раскладывает историю демо-компаний («Демо: …») по последним трём неделям, чтобы аналитика и
-- история карточек выглядели как у работающего агентства. Запускать сразу после seed-demo.mjs.
-- Объекты в работе: пришли от 1 до 20 дней назад; объекты в пуле — за последние трое суток.
WITH d AS (
SELECT l.id,
EXISTS (SELECT 1 FROM events e WHERE e.lead_id = l.id AND e.type <> 'created') AS worked
FROM leads l JOIN companies c ON c.id = l.company_id WHERE c.name LIKE 'Демо:%'
)
UPDATE leads SET created_at = CASE
WHEN d.worked THEN now() - make_interval(days => 1 + (leads.id * 7 % 20), hours => leads.id * 5 % 9)
ELSE now() - make_interval(hours => leads.id * 13 % 70, mins => leads.id * 17 % 60) END
FROM d WHERE leads.id = d.id;
-- События объекта: по порядку между его приходом и «час назад».
WITH e AS (
SELECT ev.id, l.created_at,
row_number() OVER (PARTITION BY ev.lead_id ORDER BY ev.id) - 1 AS n,
count(*) OVER (PARTITION BY ev.lead_id) AS cnt
FROM events ev JOIN leads l ON l.id = ev.lead_id JOIN companies c ON c.id = l.company_id
WHERE c.name LIKE 'Демо:%'
)
UPDATE events SET at = e.created_at + (e.n::float / e.cnt) * (now() - interval '1 hour' - e.created_at)
FROM e WHERE events.id = e.id;
-- Клиенты заведены 2–16 дней назад; их события — так же по порядку.
UPDATE clients SET created_at = now() - make_interval(days => 2 + (clients.id * 5 % 15), hours => clients.id * 3 % 8)
FROM companies c WHERE c.id = clients.company_id AND c.name LIKE 'Демо:%';
WITH e AS (
SELECT ev.id, cl.created_at,
row_number() OVER (PARTITION BY ev.client_id ORDER BY ev.id) - 1 AS n,
count(*) OVER (PARTITION BY ev.client_id) AS cnt
FROM events ev JOIN clients cl ON cl.id = ev.client_id JOIN companies c ON c.id = cl.company_id
WHERE c.name LIKE 'Демо:%' AND ev.lead_id IS NULL
)
UPDATE events SET at = e.created_at + (e.n::float / e.cnt) * (now() - interval '1 hour' - e.created_at)
FROM e WHERE events.id = e.id;
-- Запись о сделке стоит в день сделки.
UPDATE events SET at = (d.deal_date + time '14:00') AT TIME ZONE 'Asia/Yerevan'
FROM deals d JOIN companies c ON c.id = d.company_id
WHERE c.name LIKE 'Демо:%' AND events.type = 'deal' AND (events.lead_id = d.lead_id OR events.client_id = d.client_id);
-- «Взят» — по записи истории; таймер возврата в пул отсчитывается от последней смены статуса.
UPDATE leads SET taken_at = t.at
FROM (SELECT lead_id, min(at) AS at FROM events WHERE type = 'taken' GROUP BY lead_id) t, companies c
WHERE leads.id = t.lead_id AND c.id = leads.company_id AND c.name LIKE 'Демо:%';
UPDATE leads SET status_changed_at = s.at
FROM (SELECT lead_id, max(at) AS at FROM events WHERE type IN ('taken', 'status', 'deal', 'blacklisted') GROUP BY lead_id) s, companies c
WHERE leads.id = s.lead_id AND c.id = leads.company_id AND c.name LIKE 'Демо:%';
+98
View File
@@ -0,0 +1,98 @@
// Дополнительные клиенты для демо-компаний (после seed-demo.mjs): по десять на компанию,
// на разных этапах, с подобранными объектами, комментариями и напоминаниями.
// Повторный запуск безопасен: клиенты с уже существующим именем пропускаются.
//
// BASE=https://… DEMO_PASSWORD=… node scripts/seed-demo-clients.mjs
const BASE = (process.env.BASE ?? 'http://localhost:3100').replace(/\/$/, '');
const { DEMO_PASSWORD } = process.env;
if (!DEMO_PASSWORD) throw new Error('Нужен DEMO_PASSWORD');
const call = (token) => async (method, path, body) => {
const res = await fetch(`${BASE}/api${path}`, {
method,
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${JSON.stringify(data)}`);
return data;
};
const login = async (email) => call((await call()('POST', '/auth/login', { email, password: DEMO_PASSWORD })).token);
const day = (offset) => new Date(Date.now() + offset * 86_400_000);
const apt = ['apartment'];
const amd = (priceTo, extra = {}) => ({ propertyTypes: apt, region: 'Ереван', priceTo, priceCurrency: 'AMD', ...extra });
const usd = (priceTo, extra = {}) => ({ propertyTypes: apt, region: 'Ереван', priceTo, priceCurrency: 'USD', ...extra });
// stage: 'new' — только заведён; число — этап воронки по порядку; 'rejected' — отказ.
const DEMOS = [
{
key: 'rent', emails: ['demo-rent@yandp.demo', 'demo-rent-1@yandp.demo', 'demo-rent-2@yandp.demo'], phoneBase: 1100,
clients: [
{ name: 'Ольга Белова', kind: 'tenant', source: 'Анкета в Telegram', request: amd(450000, { rooms: [1, 2] }), household: { who: 'alone', pets: { count: 1, age: 'кошка, 2 года' } }, stage: 'new' },
{ name: 'Самвел и Нуне', kind: 'tenant', source: 'Рекомендация', request: amd(700000, { rooms: [2, 3], districts: ['Арабкир', 'Кентрон'] }), household: { who: 'couple' }, stage: 0, comment: 'Переезжают в начале месяца, нужна мебель и техника.' },
{ name: 'Дмитрий Орлов', kind: 'tenant', source: 'Instagram', request: usd(1200, { districts: ['Кентрон'], rooms: [2] }), household: { who: 'alone' }, stage: 1, comment: 'Работает удалённо, важен быстрый интернет и тихий двор.' },
{ name: 'Семья Хачатрян', kind: 'tenant', source: 'List.am', request: { propertyTypes: ['apartment', 'house'], region: 'Ереван', priceTo: 1000000, priceCurrency: 'AMD', rooms: [3, 4] }, household: { who: 'couple', children: { count: 3, age: '2, 5 и 8 лет' } }, stage: 2, comment: 'Нужна школа рядом. Показали два варианта, второй понравился.' },
{ name: 'Кристина Ли', kind: 'tenant', source: 'Сайт', request: usd(900, { rooms: [1, 2] }), household: { who: 'friend' }, stage: 0, comment: 'Снимают вдвоём с подругой, на полгода с продлением.' },
{ name: 'Гарик Мелконян', kind: 'tenant', source: 'Рекомендация', request: amd(300000, { rooms: [1] }), household: { who: 'alone' }, stage: 'rejected' },
{ name: 'Наталья и Игорь', kind: 'tenant', source: 'Анкета в Telegram', request: usd(1800, { rooms: [3], districts: ['Кентрон', 'Арабкир'] }), household: { who: 'couple', children: { count: 1, age: '1 год' }, pets: { count: 1, age: 'собака, 6 лет' } }, stage: 1, comment: 'С собакой — заранее уточнять у собственника.' },
{ name: 'Ашот Варданян', kind: 'tenant', source: 'Звонок', request: amd(550000, { rooms: [2, 3] }), household: { who: 'couple', children: { count: 2, age: '10 и 13 лет' } }, stage: 'new' },
{ name: 'Эмили Картер', kind: 'tenant', source: 'Партнёр', request: usd(2500, { rooms: [2, 3], districts: ['Кентрон'] }), household: { who: 'alone' }, stage: 2, comment: 'Релокация от компании, договор на год, оплата по счёту.' },
{ name: 'Студенты (трое)', kind: 'tenant', source: 'Instagram', request: amd(400000, { rooms: [2, 3] }), household: { who: 'friend' }, stage: 'rejected' },
],
},
{
key: 'sale', emails: ['demo-sale@yandp.demo', 'demo-sale-1@yandp.demo', 'demo-sale-2@yandp.demo'], phoneBase: 1150,
clients: [
{ name: 'Арам Погосян', kind: 'buyer', source: 'Рекомендация', request: usd(150000, { rooms: [2, 3] }), stage: 'new' },
{ name: 'Ирина Волкова', kind: 'buyer', source: 'Instagram', request: usd(220000, { rooms: [3], districts: ['Арабкир'] }), stage: 0, comment: 'Покупает за наличные, готова выйти на сделку быстро.' },
{ name: 'Семья Давтян', kind: 'buyer', source: 'List.am', request: { propertyTypes: ['house'], region: 'Ереван', priceTo: 350000, priceCurrency: 'USD' }, stage: 1, comment: 'Нужен дом с участком, рассматривают ипотеку.' },
{ name: 'Левон Тер-Петросян', kind: 'buyer', source: 'Сайт', request: usd(95000, { rooms: [1, 2] }), stage: 2, comment: 'Первая квартира, одобрена ипотека до $95 000.' },
{ name: 'Алексей и Мария', kind: 'buyer', source: 'Партнёр', request: usd(300000, { rooms: [3, 4], districts: ['Кентрон'] }), stage: 1, comment: 'Переезд из Москвы, смотрят новостройки в центре.' },
{ name: 'Сюзанна Айвазян', kind: 'buyer', source: 'Звонок', request: usd(130000, { rooms: [2] }), stage: 'rejected' },
{ name: 'Инвестор (2 квартиры)', kind: 'buyer', source: 'Рекомендация', request: usd(110000, { rooms: [1, 2], districts: ['Кентрон', 'Арабкир'] }), stage: 2, comment: 'Берёт под сдачу, важна доходность. Показали три объекта.' },
{ name: 'Овик Назарян', kind: 'buyer', source: 'List.am', request: usd(170000, { rooms: [3] }), stage: 0, comment: 'Сначала должен продать свою квартиру.' },
{ name: 'Джон Мюллер', kind: 'buyer', source: 'Сайт', request: usd(450000, { rooms: [3, 4], districts: ['Кентрон'] }), stage: 'new' },
{ name: 'Гаяне Симонян', kind: 'buyer', source: 'Instagram', request: usd(80000, { rooms: [1] }), stage: 'rejected' },
],
},
];
const NOTES = ['Перезвонить по вариантам', 'Согласовать время показа', 'Отправить новую подборку', 'Уточнить бюджет'];
for (const demo of DEMOS) {
const people = [];
for (const email of demo.emails) people.push(await login(email));
const [boss, a1, a2] = people;
// Агентам — по четыре клиента, руководителю — два.
const owners = [a1, a2, a1, a2, boss, a1, a2, a1, a2, boss];
const pipe = await boss('GET', '/pipelines/client');
const middle = pipe.statuses.filter((s) => !s.code).map((s) => s.id);
const rejected = pipe.statuses.find((s) => s.code === 'rejected').id;
const have = new Set((await boss('GET', '/clients?tab=all')).map((c) => c.name));
let added = 0;
for (const [i, { stage, comment, ...c }] of demo.clients.entries()) {
if (have.has(c.name)) continue;
const api = owners[i];
const { id } = await api('POST', '/clients', { ...c, phone: `+3749900${demo.phoneBase + i}` });
added++;
if (stage === 'new') continue;
const upTo = stage === 'rejected' ? 0 : Math.min(stage, middle.length - 1);
// Чем дальше этап, тем больше объектов уже подобрано; первый — показан.
const fit = (await api('GET', `/clients/${id}/suggestions`)).slice(0, stage === 'rejected' ? 1 : upTo + 1);
for (const [k, lead] of fit.entries()) {
await api('POST', `/clients/${id}/leads`, { leadId: lead.id, state: k === 0 && upTo > 0 ? 'shown' : 'offered' });
}
for (let k = 0; k <= upTo; k++) await api('POST', `/clients/${id}/status`, { statusId: middle[k] });
if (comment) await api('POST', `/clients/${id}/comments`, { text: comment });
if (stage === 'rejected') {
await api('POST', `/clients/${id}/status`, { statusId: rejected, rejectReasonId: pipe.reasons[i % pipe.reasons.length].id });
} else {
// Одно напоминание из пяти — просроченное, как бывает в жизни.
await api('POST', `/clients/${id}/next-action`, { at: day(i % 5 === 3 ? -1 : 1 + (i % 4)).toISOString(), note: NOTES[i % NOTES.length] });
}
}
console.log(`${demo.key}: добавлено клиентов — ${added}`);
}
+100
View File
@@ -0,0 +1,100 @@
-- После seed-demo-one.mjs: оставляет в пуле «Демо: Агентство» около 30 объектов и раскладывает историю
-- по датам, чтобы аналитика, динамика и карточки выглядели как у агентства, работающего три месяца.
BEGIN;
CREATE TEMP TABLE demo ON COMMIT DROP AS SELECT id FROM companies WHERE name = 'Демо: Агентство';
-- 1. Пул: остаются по 15 самых свежих нетронутых объектов аренды и продажи; остальные нетронутые удаляются.
CREATE TEMP TABLE extra ON COMMIT DROP AS
SELECT id FROM (
SELECT l.id, row_number() OVER (PARTITION BY l.deal_type ORDER BY l.id DESC) AS n
FROM leads l
WHERE l.company_id IN (SELECT id FROM demo) AND l.assignee_id IS NULL
AND NOT EXISTS (SELECT 1 FROM events e WHERE e.lead_id = l.id AND e.type <> 'created')
AND NOT EXISTS (SELECT 1 FROM client_leads cl WHERE cl.lead_id = l.id)
AND NOT EXISTS (SELECT 1 FROM viewings v WHERE v.lead_id = l.id)
AND NOT EXISTS (SELECT 1 FROM deals d WHERE d.lead_id = l.id)
) t WHERE n > 15;
DELETE FROM events WHERE lead_id IN (SELECT id FROM extra);
DELETE FROM lead_notifications WHERE lead_id IN (SELECT id FROM extra);
DELETE FROM outbox WHERE lead_id IN (SELECT id FROM extra);
DELETE FROM leads WHERE id IN (SELECT id FROM extra);
-- 2. Компания и люди работают три с лишним месяца.
UPDATE companies SET created_at = now() - interval '100 days' WHERE id IN (SELECT id FROM demo);
UPDATE users SET created_at = now() - make_interval(days => 100 - (users.id % 5)) WHERE company_id IN (SELECT id FROM demo);
UPDATE invoices SET created_at = now() - interval '100 days' WHERE company_id IN (SELECT id FROM demo);
UPDATE payments SET created_at = now() - interval '99 days' WHERE invoice_id IN (SELECT id FROM invoices WHERE company_id IN (SELECT id FROM demo));
-- 3. Объекты. Со сделкой: пришли за 8–20 дней до неё. В работе: 1–20 дней назад. В пуле: за последние трое суток.
UPDATE leads SET created_at = CASE
WHEN d.deal_date IS NOT NULL THEN (d.deal_date + time '14:00') AT TIME ZONE 'Asia/Yerevan' - make_interval(days => 8 + (leads.id % 13), hours => leads.id % 7)
WHEN w.worked THEN now() - make_interval(days => 1 + (leads.id * 7 % 20), hours => leads.id * 5 % 9)
ELSE now() - make_interval(hours => leads.id * 13 % 70, mins => leads.id * 17 % 60) END
FROM leads l
LEFT JOIN deals d ON d.lead_id = l.id
CROSS JOIN LATERAL (SELECT EXISTS (SELECT 1 FROM events e WHERE e.lead_id = l.id AND e.type <> 'created') AS worked) w
WHERE leads.id = l.id AND l.company_id IN (SELECT id FROM demo);
-- События объекта — по порядку от его прихода до сделки (или до «час назад»).
WITH e AS (
SELECT ev.id, l.created_at,
coalesce((d.deal_date + time '14:00') AT TIME ZONE 'Asia/Yerevan', now() - interval '1 hour') AS until,
row_number() OVER (PARTITION BY ev.lead_id ORDER BY ev.id) - 1 AS n,
count(*) OVER (PARTITION BY ev.lead_id) AS cnt
FROM events ev JOIN leads l ON l.id = ev.lead_id LEFT JOIN deals d ON d.lead_id = l.id
WHERE l.company_id IN (SELECT id FROM demo)
)
UPDATE events SET at = e.created_at + (e.n::float / greatest(e.cnt - 1, 1)) * (e.until - e.created_at)
FROM e WHERE events.id = e.id;
-- 4. Клиенты. Со сделкой: заведены за 10–25 дней до неё; остальные — 2–16 дней назад. События — так же по порядку.
UPDATE clients SET created_at = CASE
WHEN d.deal_date IS NOT NULL THEN (d.deal_date + time '14:00') AT TIME ZONE 'Asia/Yerevan' - make_interval(days => 10 + (clients.id % 16), hours => clients.id % 6)
ELSE now() - make_interval(days => 2 + (clients.id * 5 % 15), hours => clients.id * 3 % 8) END
FROM clients c LEFT JOIN deals d ON d.client_id = c.id
WHERE clients.id = c.id AND c.company_id IN (SELECT id FROM demo);
WITH e AS (
SELECT ev.id, cl.created_at,
coalesce((d.deal_date + time '14:00') AT TIME ZONE 'Asia/Yerevan', now() - interval '1 hour') AS until,
row_number() OVER (PARTITION BY ev.client_id ORDER BY ev.id) - 1 AS n,
count(*) OVER (PARTITION BY ev.client_id) AS cnt
FROM events ev JOIN clients cl ON cl.id = ev.client_id LEFT JOIN deals d ON d.client_id = cl.id
WHERE cl.company_id IN (SELECT id FROM demo) AND ev.lead_id IS NULL
)
UPDATE events SET at = e.created_at + (e.n::float / greatest(e.cnt - 1, 1)) * (e.until - e.created_at)
FROM e WHERE events.id = e.id;
UPDATE client_leads SET created_at = greatest(cl.created_at, l.created_at) + interval '2 hours'
FROM clients cl, leads l
WHERE client_leads.client_id = cl.id AND client_leads.lead_id = l.id AND cl.company_id IN (SELECT id FROM demo);
UPDATE deals SET created_at = (deal_date + time '14:00') AT TIME ZONE 'Asia/Yerevan' WHERE company_id IN (SELECT id FROM demo);
-- 5. «Взят» — по записи истории; таймер возврата в пул отсчитывается от последней смены статуса.
UPDATE leads SET taken_at = t.at
FROM (SELECT lead_id, min(at) AS at FROM events WHERE type = 'taken' GROUP BY lead_id) t
WHERE leads.id = t.lead_id AND leads.company_id IN (SELECT id FROM demo);
UPDATE leads SET status_changed_at = s.at
FROM (SELECT lead_id, max(at) AS at FROM events WHERE type IN ('taken', 'status', 'deal', 'blacklisted') GROUP BY lead_id) s
WHERE leads.id = s.lead_id AND leads.company_id IN (SELECT id FROM demo);
UPDATE clients SET status_changed_at = s.at
FROM (SELECT client_id, max(at) AS at FROM events WHERE type IN ('status', 'deal') AND client_id IS NOT NULL GROUP BY client_id) s
WHERE clients.id = s.client_id AND clients.company_id IN (SELECT id FROM demo);
-- 6. Выплаты и ручные записи отмечены в день выплаты и в середине своего месяца.
UPDATE payouts SET created_at = (paid_at + time '11:00') AT TIME ZONE 'Asia/Yerevan' WHERE company_id IN (SELECT id FROM demo);
UPDATE pay_adjustments SET created_at = least(now(), ((month || '-15')::date + time '12:00') AT TIME ZONE 'Asia/Yerevan') WHERE company_id IN (SELECT id FROM demo);
COMMIT;
SELECT (SELECT count(*) FROM leads WHERE company_id = c.id) AS leads,
(SELECT count(*) FROM leads WHERE company_id = c.id AND assignee_id IS NULL) AS pool,
(SELECT count(*) FROM clients WHERE company_id = c.id) AS clients,
(SELECT count(*) FROM deals WHERE company_id = c.id) AS deals,
(SELECT count(*) FROM payouts WHERE company_id = c.id) AS payouts
FROM companies c WHERE c.name = 'Демо: Агентство';
+262
View File
@@ -0,0 +1,262 @@
// Одна демо-компания «Демо: Агентство»: руководитель и пять агентов, объекты аренды и продажи
// во всех статусах, клиенты, сделки за три месяца (в том числе с двумя агентами), условия оплаты
// с планами и бонусами, ручные начисления, закрытые выплатой месяцы, оплаченный счёт.
// Берёт настоящие объявления из общей базы и работает через API — с теми же проверками, что у людей.
//
// BASE=https://… SA_TOKEN=… DEMO_PASSWORD=… node scripts/seed-demo-one.mjs
// (вместо SA_TOKEN можно SA_EMAIL и SA_PASSWORD суперадмина)
//
// После него: scripts/seed-demo-one-finish.sql — оставляет в пуле 30 объектов и раскладывает историю по датам.
// Телефоны собственников демо не вносит: они попали бы в общую базу номеров как настоящие.
const BASE = (process.env.BASE ?? 'http://localhost:3100').replace(/\/$/, '');
const { SA_TOKEN, SA_EMAIL, SA_PASSWORD, DEMO_PASSWORD } = process.env;
if (!DEMO_PASSWORD || !(SA_TOKEN || (SA_EMAIL && SA_PASSWORD))) throw new Error('Нужны DEMO_PASSWORD и SA_TOKEN (или SA_EMAIL + SA_PASSWORD)');
const call = (token) => async (method, path, body) => {
const res = await fetch(`${BASE}/api${path}`, {
method,
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${JSON.stringify(data)}`);
return data;
};
const day = (offset) => new Date(Date.now() + offset * 86_400_000);
const ymd = (d) => new Date(d.getTime() + 4 * 3_600_000).toISOString().slice(0, 10);
const today = ymd(new Date());
/** «ГГГГ-ММ» месяца, который был `back` месяцев назад. */
const month = (back) => {
const [y, m] = today.split('-').map(Number);
return new Date(Date.UTC(y, m - 1 - back, 1)).toISOString().slice(0, 7);
};
/** День сделки в месяце: в текущем — не позже сегодняшнего. */
const dealDay = (back, n) => {
const dd = back === 0 ? 1 + (n % Math.max(1, Number(today.slice(8)) - 1)) : 4 + ((n * 5) % 22);
return `${month(back)}-${String(dd).padStart(2, '0')}`;
};
const NAME = 'Демо: Агентство';
const PEOPLE = [
['Армине Саркисян', 'demo@yandp.demo'],
['Давид Петросян', 'demo-1@yandp.demo'],
['Лилит Акопян', 'demo-2@yandp.demo'],
['Нарек Арутюнян', 'demo-3@yandp.demo'],
['Мариам Казарян', 'demo-4@yandp.demo'],
['Тигран Аветисян', 'demo-5@yandp.demo'],
];
// Условия оплаты по порядку PEOPLE: оклад, процент (отдельно аренда), план и бонус за него.
const PAY = [
{ mode: 'fixed', fixed: 1500, percent: 0, currency: 'USD' },
{ mode: 'mixed', fixed: 300, percent: 30, percentRent: 40, currency: 'USD', planDeals: 2, planCommission: 3000, bonusMode: 'fixed', bonusValue: 200 },
{ mode: 'percent', fixed: 0, percent: 45, currency: 'USD', planDeals: 2, bonusMode: 'percent', bonusValue: 5 },
{ mode: 'mixed', fixed: 250, percent: 35, currency: 'USD', planCommission: 2500, bonusMode: 'fixed', bonusValue: 150 },
{ mode: 'percent', fixed: 0, percent: 40, percentRent: 50, currency: 'USD' },
{ mode: 'mixed', fixed: 200_000, percent: 25, currency: 'AMD', planDeals: 1, bonusMode: 'fixed', bonusValue: 50_000 },
];
const COMMISSION = {
objectSale: { mode: 'percent', value: 3, currency: 'USD' }, objectRent: { mode: 'percent', value: 50, currency: 'USD' },
clientBuyer: { mode: 'percent', value: 2, currency: 'USD' }, clientTenant: { mode: 'mixed', value: 50, fixed: 50, currency: 'USD' },
};
const FILTERS = [
{ name: 'Ереван: аренда квартир и домов', params: { dealType: 'rent', propertyTypes: ['apartment', 'house'], region: 'Ереван', ownersOnly: false } },
{ name: 'Ереван: продажа квартир и домов', params: { dealType: 'sale', propertyTypes: ['apartment', 'house'], region: 'Ереван', ownersOnly: false } },
];
const COMMENTS = [
'Собственник на связи, готов показать вечером после 19:00.', 'Просит предоплату за месяц и депозит.', 'Квартира свободна с понедельника.',
'Договорились о показе в субботу.', 'Собственник готов к торгу в пределах 3 %.', 'Документы в порядке, один владелец.',
'Нужен договор минимум на год.', 'Хочет закрыть сделку до конца месяца.',
];
const NEXT = ['Позвонить собственнику', 'Показ клиенту', 'Отправить договор', 'Уточнить цену'];
const apt = ['apartment'];
const amd = (priceTo, extra = {}) => ({ propertyTypes: apt, region: 'Ереван', priceTo, priceCurrency: 'AMD', ...extra });
const usd = (priceTo, extra = {}) => ({ propertyTypes: apt, region: 'Ереван', priceTo, priceCurrency: 'USD', ...extra });
// stage: 'new' — только заведён; число — этап воронки; 'rejected' — отказ; 'deal' — закроется сделкой с объектом.
const CLIENTS = [
{ name: 'Анна и Гор', kind: 'tenant', source: 'Instagram', request: amd(600000, { rooms: [2, 3] }), household: { who: 'couple', children: { count: 1, age: '4 года' } }, stage: 0 },
{ name: 'Майкл Тёрнер', kind: 'tenant', source: 'Рекомендация', request: usd(1500, { districts: ['Кентрон'] }), household: { who: 'alone', pets: { count: 1, age: 'собака, 3 года' } }, stage: 'deal' },
{ name: 'Сона Мкртчян', kind: 'tenant', source: 'Анкета в Telegram', request: amd(350000, { rooms: [1, 2] }), household: { who: 'friend' }, stage: 1, comment: 'Хочет ближе к метро, бюджет может поднять.' },
{ name: 'Семья Григорян', kind: 'tenant', source: 'List.am', request: { propertyTypes: ['apartment', 'house'], region: 'Ереван', priceTo: 900000, priceCurrency: 'AMD', rooms: [3, 4] }, household: { who: 'couple', children: { count: 2, age: '6 и 9 лет' }, pets: { count: 1, age: 'кот, 5 лет' } }, stage: 'deal' },
{ name: 'Артур Овсепян', kind: 'tenant', source: 'Сайт', request: amd(500000, { districts: ['Арабкир'] }), household: { who: 'alone' }, stage: 'rejected' },
{ name: 'Ольга Белова', kind: 'tenant', source: 'Анкета в Telegram', request: amd(450000, { rooms: [1, 2] }), household: { who: 'alone', pets: { count: 1, age: 'кошка, 2 года' } }, stage: 'new' },
{ name: 'Дмитрий Орлов', kind: 'tenant', source: 'Instagram', request: usd(1200, { districts: ['Кентрон'], rooms: [2] }), household: { who: 'alone' }, stage: 'deal' },
{ name: 'Семья Хачатрян', kind: 'tenant', source: 'List.am', request: { propertyTypes: ['apartment', 'house'], region: 'Ереван', priceTo: 1000000, priceCurrency: 'AMD', rooms: [3, 4] }, household: { who: 'couple', children: { count: 3, age: '2, 5 и 8 лет' } }, stage: 2, comment: 'Нужна школа рядом. Показали два варианта, второй понравился.' },
{ name: 'Наталья и Игорь', kind: 'tenant', source: 'Анкета в Telegram', request: usd(1800, { rooms: [3], districts: ['Кентрон', 'Арабкир'] }), household: { who: 'couple', children: { count: 1, age: '1 год' }, pets: { count: 1, age: 'собака, 6 лет' } }, stage: 1, comment: 'С собакой — заранее уточнять у собственника.' },
{ name: 'Эмили Картер', kind: 'tenant', source: 'Партнёр', request: usd(2500, { rooms: [2, 3], districts: ['Кентрон'] }), household: { who: 'alone' }, stage: 'deal' },
{ name: 'Ваган Маргарян', kind: 'buyer', source: 'Рекомендация', request: usd(180000, { rooms: [3, 4] }), stage: 'deal' },
{ name: 'Елена Смирнова', kind: 'buyer', source: 'Instagram', request: usd(250000, { districts: ['Кентрон', 'Арабкир'] }), stage: 1, comment: 'Покупает за наличные, готова выйти на сделку быстро.' },
{ name: 'Карен и Ани', kind: 'buyer', source: 'Сайт', request: { propertyTypes: ['house'], region: 'Ереван', priceTo: 400000, priceCurrency: 'USD' }, stage: 0, comment: 'Нужен дом с участком, рассматривают ипотеку.' },
{ name: 'Рубен Саакян', kind: 'buyer', source: 'List.am', request: usd(120000, { rooms: [2, 3] }), stage: 'deal' },
{ name: 'Инвестор (Дубай)', kind: 'buyer', source: 'Партнёр', request: usd(500000, { districts: ['Кентрон'] }), stage: 2, comment: 'Берёт под сдачу, важна доходность. Показали три объекта.' },
{ name: 'Арам Погосян', kind: 'buyer', source: 'Рекомендация', request: usd(150000, { rooms: [2, 3] }), stage: 'new' },
{ name: 'Левон Тер-Петросян', kind: 'buyer', source: 'Сайт', request: usd(95000, { rooms: [1, 2] }), stage: 2, comment: 'Первая квартира, одобрена ипотека до $95 000.' },
{ name: 'Алексей и Мария', kind: 'buyer', source: 'Партнёр', request: usd(300000, { rooms: [3, 4], districts: ['Кентрон'] }), stage: 1, comment: 'Переезд из Москвы, смотрят новостройки в центре.' },
{ name: 'Сюзанна Айвазян', kind: 'buyer', source: 'Звонок', request: usd(130000, { rooms: [2] }), stage: 'rejected' },
{ name: 'Джон Мюллер', kind: 'buyer', source: 'Сайт', request: usd(450000, { rooms: [3, 4], districts: ['Кентрон'] }), stage: 'new' },
];
const sa = SA_TOKEN ? call(SA_TOKEN) : call((await call()('POST', '/auth/login', { email: SA_EMAIL, password: SA_PASSWORD })).token);
if ((await sa('GET', '/admin/companies')).some((c) => c.name === NAME)) throw new Error(`${NAME}: уже есть`);
// --- компания, счёт, люди ---
const created = await sa('POST', '/admin/companies', { name: NAME, plan: 'team', billingModel: 'fixed', subscriptionEndsAt: day(365).toISOString() });
for (const inv of await sa('GET', `/admin/invoices?companyId=${created.id}&status=pending`)) await sa('POST', `/admin/invoices/${inv.id}/pay`);
const join = async ([name, email], inviteToken) => {
const { token } = await call()('POST', '/auth/register', { name, email, password: DEMO_PASSWORD });
const api = call(token);
await api('POST', `/invites/${inviteToken}/accept`);
await api('PATCH', '/me', { onboarded: true });
return { api, name, email, id: (await api('GET', '/me')).user.id };
};
const boss = await join(PEOPLE[0], created.managerUrl.split('/invite/')[1]);
const invite = (await boss.api('POST', '/invites')).token;
const agents = [];
for (const p of PEOPLE.slice(1)) agents.push(await join(p, invite));
await boss.api('DELETE', `/invites/${invite}`);
const team = [boss, ...agents];
for (const [i, m] of team.entries()) await boss.api('POST', `/members/${m.id}/pay`, { pay: PAY[i] });
// Срок возврата в пул — максимальный: иначе взятые объекты демо за сутки разбегутся обратно в пул.
await boss.api('PATCH', '/company', { agentsSeeAll: true, poolReturnHours: 720, commission: COMMISSION, slug: 'demo', reportWeekly: true, reportMonthly: true });
// --- объекты: настоящие объявления из общей базы по фильтрам компании ---
// Список пула отдаёт только 300 самых новых объектов, поэтому он читается после каждого фильтра:
// иначе объекты второго фильтра целиком вытеснят объекты первого.
const filterIds = [];
const queue = { rent: [], sale: [] };
for (const f of FILTERS) {
filterIds.push((await boss.api('POST', '/filters', { ...f, scope: 'company' })).id);
const kind = f.params.dealType;
queue[kind] = (await boss.api('GET', '/leads?tab=pool')).filter((l) => l.dealType === kind && l.price && l.photos.length);
}
console.log(`в пуле: аренда ${queue.rent.length}, продажа ${queue.sale.length}`);
const object = await boss.api('GET', '/pipelines/object');
const st = (code) => object.statuses.find((s) => s.code === code).id;
const middle = object.statuses.filter((s) => !s.code).map((s) => s.id); // Дозвонился, Встреча, Договор, В продаже
const reasons = object.reasons.filter((r) => !r.code);
// Каждый агент ведёт 14 объектов: в работе на разных этапах, три сделки, отказы, «это агент». У руководителя — четыре.
const perAgent = ['taken', 'taken', 0, 0, 1, 1, 2, 3, 'deal', 'deal', 'deal', 'rejected', 'rejected', 'agent'];
const plan = [...agents.flatMap((a) => perAgent.map((stage) => [a, stage])), [boss, 0], [boss, 1], [boss, 2], [boss, 3]];
const worked = [];
for (const [i, [who, stage]] of plan.entries()) {
// Аренда и продажа чередуются; у последних двух агентов — перекос в аренду и в продажу.
const kind = (i + (who === agents[3] ? 1 : 0)) % 2 || who === agents[4] ? (queue.sale.length ? 'sale' : 'rent') : (queue.rent.length ? 'rent' : 'sale');
const lead = queue[kind].shift();
if (!lead) break;
await who.api('POST', `/leads/${lead.id}/take`);
worked.push({ lead, who, stage, kind });
if (stage === 'taken') continue;
if (stage === 'agent') { await who.api('POST', `/leads/${lead.id}/agent`); continue; }
const upTo = stage === 'deal' ? middle.length - 1 : stage === 'rejected' ? i % 2 : stage;
for (let k = 0; k <= upTo; k++) await who.api('POST', `/leads/${lead.id}/status`, { statusId: middle[k] });
await who.api('POST', `/leads/${lead.id}/comments`, { text: COMMENTS[i % COMMENTS.length] });
if (stage === 'rejected') await who.api('POST', `/leads/${lead.id}/status`, { statusId: st('rejected'), rejectReasonId: reasons[i % reasons.length].id });
else if (typeof stage === 'number' && stage < 3) await who.api('POST', `/leads/${lead.id}/next-action`, { at: day(1 + (i % 4)).toISOString(), note: NEXT[i % 4] });
}
// Двое агентов договорились о своей комиссии по объекту.
for (const w of worked.filter((x) => x.stage === 2).slice(0, 2)) {
await w.who.api('POST', `/leads/${w.lead.id}/commission`, { rule: w.kind === 'rent' ? { mode: 'percent', value: 40 } : { mode: 'percent', value: 2.5 } });
}
// --- клиенты: по кругу между агентами ---
const clientPipe = await boss.api('GET', '/pipelines/client');
const cMiddle = clientPipe.statuses.filter((s) => !s.code).map((s) => s.id);
const cRejected = clientPipe.statuses.find((s) => s.code === 'rejected').id;
const clients = [];
for (const [i, c] of CLIENTS.entries()) {
const who = agents[i % agents.length];
const { stage, comment, ...body } = c;
const { id } = await who.api('POST', '/clients', { ...body, phone: `+3749900${String(2000 + i)}` });
clients.push({ id, who, stage, comment, kind: c.kind, name: c.name });
}
// --- сделки: по три у агента, по одной в каждом из трёх месяцев ---
// Каждая третья — с клиентом другого агента (комиссия делится), ещё треть — со своим клиентом, остальные — без клиента из CRM.
const waiting = clients.filter((c) => c.stage === 'deal');
const dealsOf = new Map();
for (const w of worked.filter((x) => x.stage === 'deal')) {
const n = dealsOf.get(w.who) ?? 0;
dealsOf.set(w.who, n + 1);
const back = 2 - n; // первая сделка — два месяца назад, третья — в этом месяце
const idx = agents.indexOf(w.who) * 3 + n;
const card = await w.who.api('GET', `/leads/${w.lead.id}`);
const expected = card.commission.expected ?? { amount: w.kind === 'rent' ? 400 : 3000, currency: 'USD' };
const want = w.kind === 'rent' ? 'tenant' : 'buyer';
const foreign = waiting.find((c) => c.kind === want && c.who !== w.who);
const own = waiting.find((c) => c.kind === want && c.who === w.who);
const pair = idx % 3 === 0 ? (foreign ?? own) : idx % 3 === 1 ? (own ?? null) : null;
const deal = { ownerCommission: expected.amount, clientCommission: 0, currency: expected.currency, date: dealDay(back, idx) };
if (pair) {
waiting.splice(waiting.indexOf(pair), 1);
await pair.who.api('POST', `/clients/${pair.id}/leads`, { leadId: w.lead.id, state: 'shown' });
for (const s of cMiddle) await pair.who.api('POST', `/clients/${pair.id}/status`, { statusId: s });
deal.clientCommission = Math.round(expected.amount * (w.kind === 'rent' ? 1 : 0.6));
deal.clientId = pair.id;
pair.stage = 'done';
} else if (idx % 2) {
// Покупатель или арендатор не из CRM, но комиссию с него агентство тоже получило.
deal.clientCommission = Math.round(expected.amount * 0.5);
}
await w.who.api('POST', `/leads/${w.lead.id}/status`, { statusId: st('deal'), deal });
}
// --- остальные клиенты: этапы, объекты, комментарии, напоминания ---
for (const [i, c] of clients.entries()) {
if (c.stage === 'done' || c.stage === 'new') continue;
if (c.stage === 'rejected') {
await c.who.api('POST', `/clients/${c.id}/status`, { statusId: cMiddle[0] });
await c.who.api('POST', `/clients/${c.id}/status`, { statusId: cRejected, rejectReasonId: clientPipe.reasons[i % clientPipe.reasons.length].id });
continue;
}
// Клиент, которому не досталось сделки, остаётся в работе на втором этапе.
const stage = c.stage === 'deal' ? 1 : c.stage;
const want = c.kind === 'tenant' ? 'rent' : 'sale';
const mine = worked.filter((w) => w.who === c.who && w.kind === want && typeof w.stage === 'number').slice(0, 2);
for (const [k, w] of mine.entries()) await c.who.api('POST', `/clients/${c.id}/leads`, { leadId: w.lead.id, state: k ? 'offered' : 'shown' });
for (const s of cMiddle.slice(0, stage + 1)) await c.who.api('POST', `/clients/${c.id}/status`, { statusId: s });
if (c.comment) await c.who.api('POST', `/clients/${c.id}/comments`, { text: c.comment });
await c.who.api('POST', `/clients/${c.id}/next-action`, { at: day(1 + (i % 5)).toISOString(), note: 'Перезвонить по вариантам' });
}
// Первому клиенту — страница с подборкой и две записи на показ.
const first = clients[0];
let pageUrl = null;
try {
const page = await first.who.api('POST', `/clients/${first.id}/page`, { match: true });
pageUrl = page.url;
const [, slug, token] = /\/([^/]+)\/([^/]+)$/.exec(page.url);
const shown = (await call()('GET', `/public/pages/${slug}/${token}`)).objects;
if (shown[0]) await call()('POST', `/public/pages/${slug}/${token}/viewings`, { leadId: shown[0].id, day: ymd(day(2)), note: 'После 18:00' });
if (shown[1]) await call()('POST', `/public/pages/${slug}/${token}/viewings`, { leadId: shown[1].id, day: ymd(day(4)) });
} catch (e) {
console.log(`страница клиента не создана: ${e.message}`);
}
// --- оплата сотрудников: ручные записи и закрытые выплатой прошлые месяцы ---
const adjust = (m, back, amount, note) => boss.api('POST', '/payroll/adjustments', { userId: m.id, month: month(back), amount, note });
await adjust(agents[3], 2, -50, 'Штраф: опоздание на показ');
await adjust(agents[0], 1, 100, 'Премия за отзыв клиента');
await adjust(agents[1], 1, -150, 'Аванс 15-го числа');
await adjust(agents[0], 0, -200, 'Аванс 10-го числа');
for (const back of [2, 1]) {
const [y, m] = month(back).split('-').map(Number);
const range = `from=${month(back)}-01&to=${month(back)}-${new Date(Date.UTC(y, m, 0)).getUTCDate()}`;
for (const r of (await boss.api('GET', `/payroll?${range}`)).agents) {
if (!(r.total > 0)) continue;
// Одному агенту в прошлом месяце недоплатили — остаток виден перерасчётом в текущем.
const partial = back === 1 && r.userId === agents[2].id;
await boss.api('POST', '/payroll/payouts', {
userId: r.userId, month: month(back), paidAt: `${month(back - 1)}-05`,
...(partial ? { amount: Math.max(0, r.total - 100), note: 'Остаток — со следующей выплатой' } : {}),
});
}
}
// Фильтры выключены, чтобы демо не росло: новые объявления в компанию не приходят.
for (const id of filterIds) await boss.api('PATCH', `/filters/${id}`, { enabled: false });
console.log(`${NAME}: готово — id ${created.id}`);
console.log(` объектов в работе: ${worked.length}, клиентов: ${clients.length}, сделок: ${worked.filter((w) => w.stage === 'deal').length}`);
console.log(` руководитель: ${boss.email}; агенты: ${agents.map((a) => a.email).join(', ')}`);
if (pageUrl) console.log(` страница клиента: ${pageUrl}`);
+181
View File
@@ -0,0 +1,181 @@
// Демо-компании для показа сервиса: «Демо: Аренда» и «Демо: Продажа».
// Берёт настоящие объявления из общей базы (через фильтр компании), раскладывает их по статусам,
// заводит клиентов, показы, сделки. Работает через API — те же проверки, что и у живых пользователей.
//
// BASE=https://… SA_EMAIL=… SA_PASSWORD=… DEMO_PASSWORD=… node scripts/seed-demo.mjs
//
// Телефоны собственников демо не вносит: они попали бы в общую базу номеров как настоящие.
const BASE = (process.env.BASE ?? 'http://localhost:3100').replace(/\/$/, '');
const { SA_EMAIL, SA_PASSWORD, DEMO_PASSWORD } = process.env;
if (!SA_EMAIL || !SA_PASSWORD || !DEMO_PASSWORD) throw new Error('Нужны SA_EMAIL, SA_PASSWORD и DEMO_PASSWORD');
const call = (token) => async (method, path, body) => {
const res = await fetch(`${BASE}/api${path}`, {
method,
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${JSON.stringify(data)}`);
return data;
};
const login = async (email, password) => call((await call()('POST', '/auth/login', { email, password })).token);
const day = (offset) => new Date(Date.now() + offset * 86_400_000);
const ymd = (d) => new Date(d.getTime() + 4 * 3_600_000).toISOString().slice(0, 10);
const DEMOS = [
{
key: 'rent', name: 'Демо: Аренда', slug: 'demo-arenda', dealType: 'rent',
people: [['Армине Саркисян', 'demo-rent@yandp.demo'], ['Давид Петросян', 'demo-rent-1@yandp.demo'], ['Лилит Акопян', 'demo-rent-2@yandp.demo']],
filter: { name: 'Ереван: аренда квартир и домов', params: { dealType: 'rent', propertyTypes: ['apartment', 'house'], region: 'Ереван', ownersOnly: false } },
commission: {
objectSale: null, objectRent: { mode: 'percent', value: 50, currency: 'USD' },
clientBuyer: null, clientTenant: { mode: 'mixed', value: 50, fixed: 50, currency: 'USD' },
},
comments: ['Собственник на связи, готов показать вечером после 19:00.', 'Просит предоплату за месяц и депозит.', 'Квартира свободна с понедельника.', 'Договорились о показе в субботу.', 'Нужен договор минимум на год.'],
clients: [
{ name: 'Анна и Гор', kind: 'tenant', source: 'Instagram', request: { propertyTypes: ['apartment'], region: 'Ереван', priceTo: 600000, priceCurrency: 'AMD', rooms: [2, 3] }, household: { who: 'couple', children: { count: 1, age: '4 года' } } },
{ name: 'Майкл Тёрнер', kind: 'tenant', source: 'Рекомендация', request: { propertyTypes: ['apartment'], region: 'Ереван', districts: ['Кентрон'], priceTo: 1500, priceCurrency: 'USD' }, household: { who: 'alone', pets: { count: 1, age: 'собака, 3 года' } } },
{ name: 'Сона Мкртчян', kind: 'tenant', source: 'Анкета в Telegram', request: { propertyTypes: ['apartment'], region: 'Ереван', priceTo: 350000, priceCurrency: 'AMD', rooms: [1, 2] }, household: { who: 'friend' } },
{ name: 'Семья Григорян', kind: 'tenant', source: 'List.am', request: { propertyTypes: ['apartment', 'house'], region: 'Ереван', priceTo: 900000, priceCurrency: 'AMD', rooms: [3, 4] }, household: { who: 'couple', children: { count: 2, age: '6 и 9 лет' }, pets: { count: 1, age: 'кот, 5 лет' } } },
{ name: 'Артур Овсепян', kind: 'tenant', source: 'Сайт', request: { propertyTypes: ['apartment'], region: 'Ереван', districts: ['Арабкир'], priceTo: 500000, priceCurrency: 'AMD' }, household: { who: 'alone' } },
],
},
{
key: 'sale', name: 'Демо: Продажа', slug: 'demo-prodazha', dealType: 'sale',
people: [['Тигран Аветисян', 'demo-sale@yandp.demo'], ['Нарек Арутюнян', 'demo-sale-1@yandp.demo'], ['Мариам Казарян', 'demo-sale-2@yandp.demo']],
filter: { name: 'Ереван: продажа квартир и домов', params: { dealType: 'sale', propertyTypes: ['apartment', 'house'], region: 'Ереван', ownersOnly: false } },
commission: {
objectSale: { mode: 'percent', value: 3, currency: 'USD' }, objectRent: null,
clientBuyer: { mode: 'percent', value: 2, currency: 'USD' }, clientTenant: null,
},
comments: ['Собственник готов к торгу в пределах 3 %.', 'Документы в порядке, один владелец.', 'Показ назначили на выходные.', 'Просит задаток при подписании.', 'Хочет закрыть сделку до конца месяца.'],
clients: [
{ name: 'Ваган Маргарян', kind: 'buyer', source: 'Рекомендация', request: { propertyTypes: ['apartment'], region: 'Ереван', priceTo: 180000, priceCurrency: 'USD', rooms: [3, 4] } },
{ name: 'Елена Смирнова', kind: 'buyer', source: 'Instagram', request: { propertyTypes: ['apartment'], region: 'Ереван', districts: ['Кентрон', 'Арабкир'], priceTo: 250000, priceCurrency: 'USD' } },
{ name: 'Карен и Ани', kind: 'buyer', source: 'Сайт', request: { propertyTypes: ['house'], region: 'Ереван', priceTo: 400000, priceCurrency: 'USD' } },
{ name: 'Рубен Саакян', kind: 'buyer', source: 'List.am', request: { propertyTypes: ['apartment'], region: 'Ереван', priceTo: 120000, priceCurrency: 'USD', rooms: [2, 3] } },
{ name: 'Инвестор (Дубай)', kind: 'buyer', source: 'Партнёр', request: { propertyTypes: ['apartment'], region: 'Ереван', districts: ['Кентрон'], priceTo: 500000, priceCurrency: 'USD' } },
],
},
];
const sa = await login(SA_EMAIL, SA_PASSWORD);
const existing = await sa('GET', '/admin/companies');
for (const demo of DEMOS) {
if (existing.some((c) => c.name === demo.name)) {
console.log(`${demo.name}: уже есть — пропускаю`);
continue;
}
// --- компания и люди ---
const created = await sa('POST', '/admin/companies', { name: demo.name, plan: 'team', billingModel: 'fixed', subscriptionEndsAt: day(365).toISOString() });
for (const inv of await sa('GET', `/admin/invoices?companyId=${created.id}&status=pending`)) await sa('POST', `/admin/invoices/${inv.id}/cancel`);
const join = async ([name, email], inviteToken) => {
const { token } = await call()('POST', '/auth/register', { name, email, password: DEMO_PASSWORD });
const api = call(token);
await api('POST', `/invites/${inviteToken}/accept`);
await api('PATCH', '/me', { onboarded: true });
return { api, name, email, id: (await api('GET', '/me')).user.id };
};
const boss = await join(demo.people[0], created.managerUrl.split('/invite/')[1]);
const invite = (await boss.api('POST', '/invites')).token;
const agents = [await join(demo.people[1], invite), await join(demo.people[2], invite)];
await boss.api('DELETE', `/invites/${invite}`);
// Условия оплаты: у руководителя оклад, у первого агента оклад + процент, у второго — только процент.
await boss.api('POST', `/members/${boss.id}/pay`, { pay: { mode: 'fixed', fixed: 1200, percent: 0, currency: 'USD' } });
await boss.api('POST', `/members/${agents[0].id}/pay`, { pay: { mode: 'mixed', fixed: 300, percent: 30, currency: 'USD' } });
await boss.api('POST', `/members/${agents[1].id}/pay`, { pay: { mode: 'percent', fixed: 0, percent: 45, currency: 'USD' } });
// Срок возврата в пул — максимальный: иначе взятые объекты демо за сутки разбегутся обратно в пул.
await boss.api('PATCH', '/company', { agentsSeeAll: true, poolReturnHours: 720, commission: demo.commission, slug: demo.slug });
// --- объекты: настоящие объявления из общей базы по фильтру компании ---
await boss.api('POST', '/filters', { ...demo.filter, scope: 'company' });
const pool = (await boss.api('GET', '/leads?tab=pool')).filter((l) => l.price && l.photos.length);
const object = await boss.api('GET', '/pipelines/object');
const st = (code) => object.statuses.find((s) => s.code === code).id;
const middle = object.statuses.filter((s) => !s.code).map((s) => s.id); // Дозвонился, Встреча, Договор, В продаже
const reasons = object.reasons.filter((r) => !r.code);
console.log(`${demo.name}: в пуле ${pool.length} объектов`);
// Кто какие объекты берёт и до какого этапа доводит: [исполнитель, этап].
const plan = [
[agents[0], 'taken'], [agents[0], 'taken'], [agents[0], 0], [agents[0], 0], [agents[0], 1], [agents[0], 2], [agents[0], 3], [agents[0], 'deal'], [agents[0], 'rejected'],
[agents[1], 'taken'], [agents[1], 0], [agents[1], 1], [agents[1], 1], [agents[1], 3], [agents[1], 'deal'], [agents[1], 'rejected'], [agents[1], 'agent'],
[boss, 0], [boss, 2], [boss, 3],
];
const worked = [];
for (const [i, [who, stage]] of plan.entries()) {
const lead = pool[i];
if (!lead) break;
await who.api('POST', `/leads/${lead.id}/take`);
const entry = { lead, who, stage };
worked.push(entry);
if (stage === 'taken') continue;
if (stage === 'agent') { await who.api('POST', `/leads/${lead.id}/agent`); continue; }
const upTo = stage === 'deal' ? middle.length - 1 : stage === 'rejected' ? 0 : stage;
for (let k = 0; k <= upTo; k++) await who.api('POST', `/leads/${lead.id}/status`, { statusId: middle[k] });
await who.api('POST', `/leads/${lead.id}/comments`, { text: demo.comments[i % demo.comments.length] });
if (stage === 'rejected') await who.api('POST', `/leads/${lead.id}/status`, { statusId: st('rejected'), rejectReasonId: reasons[i % reasons.length].id });
else if (typeof stage === 'number' && stage < 3) {
await who.api('POST', `/leads/${lead.id}/next-action`, { at: day(1 + (i % 4)).toISOString(), note: ['Позвонить собственнику', 'Показ клиенту', 'Отправить договор', 'Уточнить цену'][i % 4] });
}
}
// Агент договорился о своей комиссии по одному объекту.
const custom = worked.find((w) => w.stage === 2);
if (custom) await custom.who.api('POST', `/leads/${custom.lead.id}/commission`, { rule: demo.dealType === 'rent' ? { mode: 'percent', value: 40 } : { mode: 'percent', value: 2.5 } });
// --- клиенты ---
const clientPipe = await boss.api('GET', '/pipelines/client');
const cMiddle = clientPipe.statuses.filter((s) => !s.code).map((s) => s.id);
const cDeal = clientPipe.statuses.find((s) => s.code === 'deal').id;
const cRejected = clientPipe.statuses.find((s) => s.code === 'rejected').id;
const owners = [agents[0], agents[1], agents[0], agents[1], boss];
const clients = [];
for (const [i, c] of demo.clients.entries()) {
const who = owners[i];
const { id } = await who.api('POST', '/clients', { ...c, phone: `+3749900${String(1000 + (demo.key === 'rent' ? 0 : 50) + i)}` });
clients.push({ id, who, ...c });
}
// Первый клиент: подборка, страница, запись на показ.
const first = clients[0];
const page = await first.who.api('POST', `/clients/${first.id}/page`, { match: true });
await first.who.api('POST', `/clients/${first.id}/status`, { statusId: cMiddle[0] });
const [, slug, token] = /\/([^/]+)\/([^/]+)$/.exec(page.url);
const shown = (await call()('GET', `/public/pages/${slug}/${token}`)).objects;
if (shown[0]) await call()('POST', `/public/pages/${slug}/${token}/viewings`, { leadId: shown[0].id, day: ymd(day(2)), note: 'После 18:00' });
if (shown[1]) await call()('POST', `/public/pages/${slug}/${token}/viewings`, { leadId: shown[1].id, day: ymd(day(4)) });
// Второй клиент: ему показали объект, который довели до сделки, — общая сделка с одной комиссией.
const dealEntry = worked.find((w) => w.stage === 'deal' && w.who === clients[1].who) ?? worked.find((w) => w.stage === 'deal');
const second = clients[1];
if (dealEntry) {
const w = dealEntry.who;
if (second.who !== w) await boss.api('POST', `/clients/${second.id}/assign`, { userId: w.id });
await w.api('POST', `/clients/${second.id}/leads`, { leadId: dealEntry.lead.id, state: 'shown' });
for (const s of cMiddle) await w.api('POST', `/clients/${second.id}/status`, { statusId: s });
const card = await w.api('GET', `/leads/${dealEntry.lead.id}`);
const expected = card.commission.expected ?? { amount: 1000, currency: 'USD' };
await w.api('POST', `/leads/${dealEntry.lead.id}/status`, { statusId: st('deal'), deal: { commission: expected.amount, currency: expected.currency, date: ymd(day(-2)), clientId: second.id } });
}
// Остальные сделки по объектам — без клиента из CRM.
for (const w of worked.filter((x) => x.stage === 'deal' && x !== dealEntry)) {
const card = await w.who.api('GET', `/leads/${w.lead.id}`);
const expected = card.commission.expected ?? { amount: 1000, currency: 'USD' };
await w.who.api('POST', `/leads/${w.lead.id}/status`, { statusId: st('deal'), deal: { commission: expected.amount, currency: expected.currency, date: ymd(day(-6)) } });
}
// Третий и четвёртый клиенты — в работе с прикреплёнными объектами; пятый — отказ.
for (const [i, c] of clients.slice(2, 4).entries()) {
const mine = worked.filter((w) => w.who === c.who && typeof w.stage === 'number').slice(i, i + 2);
for (const [k, w] of mine.entries()) await c.who.api('POST', `/clients/${c.id}/leads`, { leadId: w.lead.id, state: k ? 'offered' : 'shown' });
for (const s of cMiddle.slice(0, i + 1)) await c.who.api('POST', `/clients/${c.id}/status`, { statusId: s });
await c.who.api('POST', `/clients/${c.id}/comments`, { text: i ? 'Смотрели два варианта, думают до пятницы.' : 'Хочет ближе к метро, бюджет может поднять.' });
await c.who.api('POST', `/clients/${c.id}/next-action`, { at: day(1 + i).toISOString(), note: 'Перезвонить по вариантам' });
}
const last = clients[4];
await last.who.api('POST', `/clients/${last.id}/status`, { statusId: cMiddle[0] });
await last.who.api('POST', `/clients/${last.id}/status`, { statusId: cRejected, rejectReasonId: clientPipe.reasons[0].id });
console.log(`${demo.name}: готово — id ${created.id}, адрес ${demo.slug}, страница клиента: ${page.url}`);
console.log(` руководитель: ${boss.email}; агенты: ${agents.map((a) => a.email).join(', ')}`);
}