Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
182 lines
15 KiB
JavaScript
182 lines
15 KiB
JavaScript
// Демо-компании для показа сервиса: «Демо: Аренда» и «Демо: Продажа».
|
|
// Берёт настоящие объявления из общей базы (через фильтр компании), раскладывает их по статусам,
|
|
// заводит клиентов, показы, сделки. Работает через 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(', ')}`);
|
|
}
|