Lead Radar: исходный код
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
9749da5ae7
201 files changed
+97685
No files matched your search
@@ -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;
|
||||
@@ -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 'Демо:%';
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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 = 'Демо: Агентство';
|
||||
@@ -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}`);
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
Reference in new issue
Block a user