- CRM: configurable pipeline (default: New → Contact → Meeting → Negotiation → Connected / Lost), drag-and-drop board, lead card with call/meeting/note history, photos and files, tasks with Telegram reminders at the due time, map of venues (OpenStreetMap via Leaflet), lost reason. Sales reps see only their own leads. - "Connected" in one click: a venue becomes a club card partner with a one-time owner link to the work bot; an agency gets a code and a client link. The sales rep is recorded on the partner for the bonus. - Sales bonus formula in settings (per venue, per agency, % of revenue from the rep's agency clients for N months); defaults are 0 until the owner sets them. - Agencies (superadmin): clients, gift subscriptions, reward by month, payouts, client link, edit. Venues: offers by membership level (5 languages), staff, card checks, 8-week activity, owner link, map point. - Schema: SalesStage/SalesLead/SalesActivity/SalesTask; salesId on Agency and Partner; lat/lng/phone/note on Partner; agencyId on Payout. - Tests for stage rules, conversion, task reminders and bonus; dev:crm-demo. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
93 lines
6.3 KiB
TypeScript
93 lines
6.3 KiB
TypeScript
/**
|
|
* Локальная разработка: демо для CRM продажника и раздела «Партнёры» (этап 4).
|
|
* pnpm --filter @barev/api dev:crm-demo
|
|
* Создаёт продажника «Давид Саргсян», лиды по этапам с точками на карте Еревана, историю и задачи.
|
|
* Повторный запуск пересоздаёт демо-лиды (по имени продажника).
|
|
*/
|
|
import { getDb } from '@barev/db';
|
|
import { env } from '../src/env.ts';
|
|
|
|
if (env.isProd) throw new Error('Только для разработки');
|
|
|
|
const db = getDb();
|
|
const H = 3600_000;
|
|
const now = Date.now();
|
|
const ago = (h: number) => new Date(now - h * H);
|
|
|
|
const sales =
|
|
(await db.adminUser.findFirst({ where: { name: 'Давид Саргсян' } })) ?? (await db.adminUser.create({ data: { name: 'Давид Саргсян', roles: ['SALES'] } }));
|
|
const owner = await db.adminUser.findFirst({ where: { roles: { has: 'SUPERADMIN' } } });
|
|
|
|
await db.salesLead.deleteMany({ where: { ownerId: sales.id, partnerId: null, agencyId: null } });
|
|
await db.salesTask.deleteMany({ where: { assigneeId: sales.id } });
|
|
|
|
const stages = Object.fromEntries((await db.salesStage.findMany()).map((s) => [s.id, s]));
|
|
const S = { new: 'stg_new', contact: 'stg_contact', meeting: 'stg_meeting', talks: 'stg_talks', lost: 'stg_lost' } as const;
|
|
if (!stages[S.new]) throw new Error('Нет этапов воронки — примените миграции');
|
|
|
|
type Act = { type: 'CALL' | 'MEETING' | 'NOTE' | 'STAGE'; text: string; h: number };
|
|
async function lead(
|
|
data: { kind: 'VENUE' | 'AGENCY'; name: string; category?: string; contactName?: string; phone?: string; telegram?: string; address?: string; lat?: number; lng?: number; note?: string; lostReason?: string },
|
|
stageId: string,
|
|
stageH: number,
|
|
acts: Act[] = [],
|
|
tasks: { title: string; inH: number }[] = [],
|
|
) {
|
|
const l = await db.salesLead.create({ data: { ...data, stageId, stageChangedAt: ago(stageH), ownerId: sales.id, createdAt: ago(Math.max(stageH, ...acts.map((a) => a.h)) + 2) } });
|
|
for (const a of acts) await db.salesActivity.create({ data: { leadId: l.id, authorId: sales.id, type: a.type, text: a.text, createdAt: ago(a.h) } });
|
|
for (const t of tasks) await db.salesTask.create({ data: { leadId: l.id, assigneeId: sales.id, title: t.title, dueAt: new Date(now + t.inH * H), createdById: sales.id } });
|
|
return l;
|
|
}
|
|
|
|
await lead({ kind: 'VENUE', name: 'Gemini Coffee', category: 'cafe', address: 'Абовяна 12', lat: 40.1835, lng: 44.5181, phone: '+374 91 222 333' }, S.new, 20);
|
|
await lead({ kind: 'VENUE', name: 'Tumanyan Shaurma', category: 'restaurant', address: 'Туманяна 40', lat: 40.1868, lng: 44.5108 }, S.new, 6, [], [{ title: 'Позвонить', inH: 3 }]);
|
|
await lead(
|
|
{ kind: 'VENUE', name: 'Ankyun Wine Bar', category: 'restaurant', contactName: 'Ани, управляющая', phone: '+374 93 555 010', telegram: '@ankyun_bar', address: 'Сарьяна 8', lat: 40.1822, lng: 44.5064 },
|
|
S.contact,
|
|
50,
|
|
[
|
|
{ type: 'CALL', text: 'Интересно, просят условия письменно. Собственник решает по средам.', h: 50 },
|
|
{ type: 'STAGE', text: 'Контакт', h: 50 },
|
|
],
|
|
[{ title: 'Отправить условия', inH: -5 }],
|
|
);
|
|
await lead(
|
|
{ kind: 'VENUE', name: 'Lavash Restaurant', category: 'restaurant', contactName: 'Армен, собственник', phone: '+374 99 100 200', address: 'Туманяна 21', lat: 40.1859, lng: 44.5137 },
|
|
S.meeting,
|
|
30,
|
|
[
|
|
{ type: 'CALL', text: 'Договорились о встрече в четверг 15:00', h: 80 },
|
|
{ type: 'MEETING', text: 'Готовы дать −10% по карте и десерт в подарок для Elite. Нужен макет наклейки на дверь.', h: 30 },
|
|
],
|
|
[{ title: 'Привезти наклейку и согласовать предложения', inH: 26 }],
|
|
);
|
|
await lead(
|
|
{ kind: 'VENUE', name: 'Salon Nuar', category: 'beauty', contactName: 'Нуне', phone: '+374 77 300 400', address: 'Пушкина 33', lat: 40.1801, lng: 44.5089 },
|
|
S.talks,
|
|
12,
|
|
[{ type: 'MEETING', text: 'Хотят −15% на первое посещение, обсуждают с партнёром.', h: 12 }],
|
|
[{ title: 'Узнать решение', inH: 48 }],
|
|
);
|
|
await lead({ kind: 'AGENCY', name: 'Yerevan Homes', contactName: 'Лусине, директор', phone: '+374 98 777 888', telegram: '@yerevanhomes' }, S.contact, 26, [{ type: 'CALL', text: 'Каждый месяц ~15 клиентов из РФ. Интересует подарочный месяц для клиентов.', h: 26 }], [{ title: 'Встреча в офисе', inH: 20 }]);
|
|
await lead({ kind: 'AGENCY', name: 'Relocate.am', contactName: 'Гор', telegram: '@relocate_am' }, S.talks, 70, [{ type: 'MEETING', text: 'Готовы, ждут ставку 12% вместо 10%.', h: 70 }]);
|
|
await lead({ kind: 'VENUE', name: 'Kond Bakery', category: 'cafe', address: 'Конд', lat: 40.1773, lng: 44.4991, lostReason: 'Не устроили условия' }, S.lost, 100, [{ type: 'STAGE', text: 'Отказ: Не устроили условия', h: 100 }]);
|
|
|
|
// подключённые: точка на карте у существующих партнёров, продажник — у части
|
|
const partners = await db.partner.findMany({ orderBy: { createdAt: 'asc' } });
|
|
const spots: [number, number][] = [
|
|
[40.1811, 44.5136],
|
|
[40.1776, 44.5126],
|
|
[40.1893, 44.5153],
|
|
];
|
|
for (const [i, p] of partners.entries()) {
|
|
const s = spots[i % spots.length]!;
|
|
await db.partner.update({ where: { id: p.id }, data: { lat: s[0] + i * 0.0012, lng: s[1] - i * 0.0009, salesId: i === 0 ? sales.id : (p.salesId ?? owner?.id ?? null) } });
|
|
}
|
|
const agency = await db.agency.findFirst({ orderBy: { createdAt: 'asc' } });
|
|
if (agency) await db.agency.update({ where: { id: agency.id }, data: { salesId: sales.id } });
|
|
// задача без лида
|
|
await db.salesTask.create({ data: { assigneeId: sales.id, title: 'Обойти Каскад: кафе без партнёрства', dueAt: new Date(now + 30 * H), createdById: sales.id } });
|
|
|
|
console.log(`CRM демо готово: продажник ${sales.name}, лидов 8, партнёров на карте ${partners.length}.`);
|
|
process.exit(0);
|