Backoffice stage 4: partners (agencies, venues) and sales CRM

- 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>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 03:02:03 +04:00
1 parent a848bb13e2
commit ac786cc5cc
30 files changed
+4311 -11

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
/**
* Локальная разработка: демо для 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);