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