- 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.8 KiB
TypeScript
93 lines
6.8 KiB
TypeScript
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { convertLead, DomainError, dueSalesTasks, moveLead, salesBonus } from '../src/index.ts';
|
|
import { db, fixtures, makeCtx, NOW } from './helpers.ts';
|
|
|
|
beforeEach(async () => {
|
|
await fixtures();
|
|
await db.salesStage.createMany({
|
|
data: [
|
|
{ id: 's_new', name: 'Новый', kind: 'OPEN', sortOrder: 0 },
|
|
{ id: 's_meet', name: 'Встреча', kind: 'OPEN', sortOrder: 1 },
|
|
{ id: 's_won', name: 'Подключён', kind: 'WON', sortOrder: 2 },
|
|
{ id: 's_lost', name: 'Отказ', kind: 'LOST', sortOrder: 3 },
|
|
],
|
|
});
|
|
});
|
|
afterAll(() => db.$disconnect());
|
|
|
|
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
|
|
const setting = (key: string, value: number) => db.setting.upsert({ where: { key }, create: { key, value }, update: { value } });
|
|
|
|
describe('CRM продажника', () => {
|
|
it('этапы: «Подключён» только через подключение, «Отказ» — с причиной; всё пишется в историю', async () => {
|
|
const { ctx } = makeCtx(NOW);
|
|
const sales = await db.adminUser.create({ data: { name: 'Давид', roles: ['SALES'] } });
|
|
const lead = await db.salesLead.create({ data: { kind: 'VENUE', name: 'Lavash', stageId: 's_new', ownerId: sales.id } });
|
|
await moveLead(ctx, { leadId: lead.id, stageId: 's_meet', byAdminId: sales.id });
|
|
expect(await code(moveLead(ctx, { leadId: lead.id, stageId: 's_won', byAdminId: sales.id }))).toBe('LEAD_NOT_CONVERTED');
|
|
expect(await code(moveLead(ctx, { leadId: lead.id, stageId: 's_lost', byAdminId: sales.id }))).toBe('REASON_REQUIRED');
|
|
await moveLead(ctx, { leadId: lead.id, stageId: 's_lost', byAdminId: sales.id, lostReason: 'Дорого' });
|
|
const after = await db.salesLead.findUniqueOrThrow({ where: { id: lead.id }, include: { activities: { orderBy: { createdAt: 'asc' } } } });
|
|
expect(after).toMatchObject({ stageId: 's_lost', lostReason: 'Дорого' });
|
|
expect(after.activities.map((a) => a.text)).toEqual(['Встреча', 'Отказ: Дорого']);
|
|
});
|
|
|
|
it('заведение «Подключён»: партнёр с продажником и одноразовая ссылка собственнику', async () => {
|
|
const { ctx } = makeCtx(NOW);
|
|
const sales = await db.adminUser.create({ data: { name: 'Давид', roles: ['SALES'] } });
|
|
const lead = await db.salesLead.create({ data: { kind: 'VENUE', name: 'Lavash', category: 'restaurant', address: 'Туманяна 21', lat: 40.18, lng: 44.51, stageId: 's_meet', ownerId: sales.id } });
|
|
const r = await convertLead(ctx, { leadId: lead.id, byAdminId: sales.id });
|
|
expect(r.kind).toBe('VENUE');
|
|
expect(r.url).toMatch(/\?start=po-/);
|
|
const partner = await db.partner.findUniqueOrThrow({ where: { id: (r as { partnerId: string }).partnerId } });
|
|
expect(partner).toMatchObject({ name: 'Lavash', category: 'restaurant', salesId: sales.id, lat: 40.18 });
|
|
expect(partner.ownerInviteCode).toBeTruthy();
|
|
expect(await db.salesLead.findUniqueOrThrow({ where: { id: lead.id } })).toMatchObject({ stageId: 's_won', partnerId: partner.id });
|
|
expect(await code(convertLead(ctx, { leadId: lead.id, byAdminId: sales.id }))).toBe('ALREADY_CONVERTED');
|
|
});
|
|
|
|
it('агентство «Подключён»: код уникален, ссылка для клиентов, % по умолчанию из настроек', async () => {
|
|
const { ctx } = makeCtx(NOW);
|
|
const sales = await db.adminUser.create({ data: { name: 'Давид', roles: ['SALES'] } });
|
|
const lead = await db.salesLead.create({ data: { kind: 'AGENCY', name: 'Yerevan Homes', stageId: 's_new', ownerId: sales.id } });
|
|
expect(await code(convertLead(ctx, { leadId: lead.id, byAdminId: sales.id, agencyCode: 'домик' }))).toBe('AGENCY_CODE_FORMAT');
|
|
expect(await code(convertLead(ctx, { leadId: lead.id, byAdminId: sales.id, agencyCode: 'domik' }))).toBe('AGENCY_CODE_TAKEN');
|
|
const r = await convertLead(ctx, { leadId: lead.id, byAdminId: sales.id, agencyCode: 'yhomes' });
|
|
expect(r.url).toMatch(/\?start=a-YHOMES$/);
|
|
expect(await db.agency.findUniqueOrThrow({ where: { code: 'YHOMES' } })).toMatchObject({ name: 'Yerevan Homes', commissionPct: 10, salesId: sales.id });
|
|
});
|
|
|
|
it('задачи: в срок — одно напоминание, выполненные и будущие — нет', async () => {
|
|
const { ctx, events, clock } = makeCtx(NOW);
|
|
const sales = await db.adminUser.create({ data: { name: 'Давид', roles: ['SALES'] } });
|
|
const due = await db.salesTask.create({ data: { assigneeId: sales.id, title: 'Позвонить', dueAt: new Date(NOW.getTime() - 60_000) } });
|
|
await db.salesTask.create({ data: { assigneeId: sales.id, title: 'Потом', dueAt: new Date(NOW.getTime() + 3600_000) } });
|
|
await db.salesTask.create({ data: { assigneeId: sales.id, title: 'Сделано', dueAt: new Date(NOW.getTime() - 60_000), doneAt: NOW } });
|
|
expect(await dueSalesTasks(ctx)).toBe(1);
|
|
expect(events).toEqual([{ type: 'sales.taskDue', taskId: due.id }]);
|
|
expect(await dueSalesTasks(ctx)).toBe(0);
|
|
clock.now = new Date(NOW.getTime() + 2 * 3600_000);
|
|
expect(await dueSalesTasks(ctx)).toBe(1);
|
|
});
|
|
|
|
it('бонус: ставки за заведения и агентства + % выручки клиентов агентства в первые N месяцев', async () => {
|
|
const { ctx } = makeCtx(NOW);
|
|
const sales = await db.adminUser.create({ data: { name: 'Давид', roles: ['SALES'] } });
|
|
const from = new Date(NOW.getTime() - 10 * 86_400_000);
|
|
const to = new Date(NOW.getTime() + 86_400_000);
|
|
expect((await salesBonus(ctx, sales.id, from, to)).total).toBe(0);
|
|
|
|
await setting('sales.bonusPerVenue', 10000);
|
|
await setting('sales.bonusPerAgency', 20000);
|
|
await setting('sales.bonusAgencyPct', 5);
|
|
await db.partner.create({ data: { name: 'Cafe', category: 'cafe', offer: {}, salesId: sales.id, createdAt: NOW } });
|
|
const agency = await db.agency.create({ data: { code: 'NEWAG', name: 'New', salesId: sales.id, createdAt: new Date(NOW.getTime() - 86_400_000) } });
|
|
const user = await db.user.create({ data: { telegramId: 990001n } });
|
|
const client = await db.client.create({ data: { userId: user.id, cardCode: 'BONUS1', agencyId: agency.id, consentAt: NOW } });
|
|
await db.subscription.create({ data: { clientId: client.id, plan: 'BAREV_PLUS', source: 'PAID', amount: 40000, startsAt: NOW, endsAt: new Date(NOW.getTime() + 30 * 86_400_000), createdAt: NOW } });
|
|
|
|
const b = await salesBonus(ctx, sales.id, from, to);
|
|
expect(b).toMatchObject({ venues: 1, agencies: 1, revenue: 40000, fromVenues: 10000, fromAgencies: 20000, fromRevenue: 2000, total: 32000 });
|
|
});
|
|
});
|