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:
1 parent
a848bb13e2
commit
ac786cc5cc
30 files changed
+4311
-11
No files matched your search
@@ -25,6 +25,8 @@ export type DomainEvent =
|
||||
| { type: 'concierge.message'; messageId: string }
|
||||
/** диалог передали коллеге — ему уведомление в рабочий бот */
|
||||
| { type: 'chat.assigned'; threadId: string; assigneeId: string; byAdminId: string }
|
||||
/** срок задачи продажника — напоминание в рабочий бот */
|
||||
| { type: 'sales.taskDue'; taskId: string }
|
||||
| { type: 'lead.submitted'; leadId: string }
|
||||
| { type: 'clientRequest.created'; requestId: string }
|
||||
| { type: 'webRequest.created'; requestId: string };
|
||||
@@ -81,7 +83,12 @@ export type DomainErrorCode =
|
||||
| 'OFFER_NOT_AVAILABLE'
|
||||
| 'OFFER_LIMIT_REACHED'
|
||||
| 'CONCIERGE_SUBSCRIBERS_ONLY'
|
||||
| 'ASSIGNEE_NO_ACCESS';
|
||||
| 'ASSIGNEE_NO_ACCESS'
|
||||
| 'LEAD_NOT_CONVERTED'
|
||||
| 'ALREADY_CONVERTED'
|
||||
| 'AGENCY_CODE_FORMAT'
|
||||
| 'AGENCY_CODE_TAKEN'
|
||||
| 'REASON_REQUIRED';
|
||||
|
||||
/** Имя очереди BullMQ, через которую API и боты передают события воркеру */
|
||||
export const EVENTS_QUEUE = 'barev-events';
|
||||
@@ -15,4 +15,5 @@ export * from './relocation.ts';
|
||||
export * from './clientRequests.ts';
|
||||
export * from './concierge.ts';
|
||||
export * from './chats.ts';
|
||||
export * from './sales.ts';
|
||||
export * from './partners.ts';
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* CRM продажника (этап 4): лиды-заведения и лиды-агентства, воронка, история контактов, задачи,
|
||||
* «Подключён» → партнёр/агентство одним кликом, бонус по формуле из настроек.
|
||||
*/
|
||||
import { botLink, encodeStartPayload, normalizeAgencyCode } from '@barev/core';
|
||||
import type { Prisma } from '@barev/db';
|
||||
import { DomainError, type Ctx } from './context.ts';
|
||||
import { createOwnerInvite } from './partners.ts';
|
||||
import { getSettings } from './settings.ts';
|
||||
|
||||
const LOCALES = ['ru', 'en', 'zh', 'hy', 'es'] as const;
|
||||
const emptyLocalized = () => Object.fromEntries(LOCALES.map((l) => [l, ''])) as Record<(typeof LOCALES)[number], string>;
|
||||
|
||||
/** Ссылка агентства для клиентов: клиентский бот с кодом агентства (подарок и закрепление) */
|
||||
export function agencyLink(code: string) {
|
||||
return botLink(process.env.CLIENT_BOT_USERNAME ?? 'barev_club_bot', encodeStartPayload({ agency: code }));
|
||||
}
|
||||
|
||||
async function stageOf(ctx: Ctx, kind: 'WON' | 'LOST') {
|
||||
const s = await ctx.db.salesStage.findFirst({ where: { kind, active: true }, orderBy: { sortOrder: 'asc' } });
|
||||
if (!s) throw new DomainError('NOT_FOUND');
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Перевести лид на другой этап. На «Подключён» — только через convertLead (там создаётся партнёр) */
|
||||
export async function moveLead(ctx: Ctx, input: { leadId: string; stageId: string; byAdminId: string; lostReason?: string | null }) {
|
||||
const [lead, stage] = await Promise.all([ctx.db.salesLead.findUnique({ where: { id: input.leadId } }), ctx.db.salesStage.findUnique({ where: { id: input.stageId } })]);
|
||||
if (!lead || !stage) throw new DomainError('NOT_FOUND');
|
||||
if (lead.stageId === stage.id) return lead;
|
||||
if (stage.kind === 'WON' && !lead.partnerId && !lead.agencyId) throw new DomainError('LEAD_NOT_CONVERTED');
|
||||
if (stage.kind === 'LOST' && !input.lostReason?.trim()) throw new DomainError('REASON_REQUIRED');
|
||||
const updated = await ctx.db.salesLead.update({
|
||||
where: { id: lead.id },
|
||||
data: { stageId: stage.id, stageChangedAt: ctx.now(), lostReason: stage.kind === 'LOST' ? input.lostReason!.trim() : null },
|
||||
});
|
||||
await ctx.db.salesActivity.create({ data: { leadId: lead.id, authorId: input.byAdminId, type: 'STAGE', text: stage.kind === 'LOST' ? `${stage.name}: ${input.lostReason!.trim()}` : stage.name } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* «Подключён»: заведение становится партнёром клубной карты (и сразу ссылка для собственника в рабочий бот),
|
||||
* агентство — агентством с кодом (и ссылка для его клиентов). Продажник закрепляется за партнёром — для бонуса.
|
||||
*/
|
||||
export async function convertLead(ctx: Ctx, input: { leadId: string; byAdminId: string; agencyCode?: string; commissionPct?: number }) {
|
||||
const lead = await ctx.db.salesLead.findUnique({ where: { id: input.leadId } });
|
||||
if (!lead) throw new DomainError('NOT_FOUND');
|
||||
if (lead.partnerId || lead.agencyId) throw new DomainError('ALREADY_CONVERTED');
|
||||
const won = await stageOf(ctx, 'WON');
|
||||
|
||||
if (lead.kind === 'VENUE') {
|
||||
const partner = await ctx.db.partner.create({
|
||||
data: {
|
||||
name: lead.name,
|
||||
category: lead.category ?? 'other',
|
||||
offer: emptyLocalized(),
|
||||
address: lead.address,
|
||||
lat: lead.lat,
|
||||
lng: lead.lng,
|
||||
phone: lead.phone,
|
||||
salesId: lead.ownerId,
|
||||
note: [lead.contactName, lead.telegram].filter(Boolean).join(' · ') || null,
|
||||
},
|
||||
});
|
||||
const invite = await createOwnerInvite(ctx, partner.id);
|
||||
await ctx.db.salesLead.update({ where: { id: lead.id }, data: { partnerId: partner.id, stageId: won.id, stageChangedAt: ctx.now() } });
|
||||
await ctx.db.salesActivity.create({ data: { leadId: lead.id, authorId: input.byAdminId, type: 'WON', text: won.name } });
|
||||
return { kind: 'VENUE' as const, partnerId: partner.id, url: invite.url };
|
||||
}
|
||||
|
||||
const code = normalizeAgencyCode(input.agencyCode ?? '');
|
||||
if (!code) throw new DomainError('AGENCY_CODE_FORMAT');
|
||||
if (await ctx.db.agency.findUnique({ where: { code } })) throw new DomainError('AGENCY_CODE_TAKEN');
|
||||
const settings = await getSettings(ctx.db);
|
||||
const agency = await ctx.db.agency.create({
|
||||
data: {
|
||||
code,
|
||||
name: lead.name,
|
||||
commissionPct: input.commissionPct ?? settings['agency.defaultCommissionPct'],
|
||||
contact: [lead.contactName, lead.phone, lead.telegram].filter(Boolean).join(' · ') || null,
|
||||
salesId: lead.ownerId,
|
||||
},
|
||||
});
|
||||
await ctx.db.salesLead.update({ where: { id: lead.id }, data: { agencyId: agency.id, stageId: won.id, stageChangedAt: ctx.now() } });
|
||||
await ctx.db.salesActivity.create({ data: { leadId: lead.id, authorId: input.byAdminId, type: 'WON', text: won.name } });
|
||||
return { kind: 'AGENCY' as const, agencyId: agency.id, url: agencyLink(code) };
|
||||
}
|
||||
|
||||
/** Задачи, срок которых наступил, — напоминание продажнику в рабочий бот (воркер раз в минуту) */
|
||||
export async function dueSalesTasks(ctx: Ctx) {
|
||||
const due = await ctx.db.salesTask.findMany({ where: { doneAt: null, remindedAt: null, dueAt: { lte: ctx.now() } }, select: { id: true }, take: 100 });
|
||||
for (const t of due) {
|
||||
await ctx.db.salesTask.update({ where: { id: t.id }, data: { remindedAt: ctx.now() } });
|
||||
await ctx.notify.emit({ type: 'sales.taskDue', taskId: t.id });
|
||||
}
|
||||
return due.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Бонус продажника за период по формуле из настроек:
|
||||
* подключённые заведения × ставка + агентства × ставка + % выручки клиентов его агентств
|
||||
* (первые N месяцев после подключения агентства).
|
||||
*/
|
||||
export async function salesBonus(ctx: Ctx, salesId: string, from: Date, to: Date) {
|
||||
const s = await getSettings(ctx.db);
|
||||
const months = s['sales.bonusAgencyMonths'];
|
||||
const [venues, agencies, myAgencies] = await Promise.all([
|
||||
ctx.db.partner.count({ where: { salesId, createdAt: { gte: from, lt: to } } }),
|
||||
ctx.db.agency.count({ where: { salesId, createdAt: { gte: from, lt: to } } }),
|
||||
ctx.db.agency.findMany({ where: { salesId }, select: { id: true, createdAt: true } }),
|
||||
]);
|
||||
// выручка клиентов агентства, пока агентство «молодое» (N месяцев с подключения)
|
||||
let revenue = 0;
|
||||
for (const a of myAgencies) {
|
||||
const until = new Date(a.createdAt);
|
||||
until.setMonth(until.getMonth() + months);
|
||||
const lo = from > a.createdAt ? from : a.createdAt;
|
||||
const hi = to < until ? to : until;
|
||||
if (lo >= hi) continue;
|
||||
const where: Prisma.ClientWhereInput = { agencyId: a.id };
|
||||
const [orders, subs] = await Promise.all([
|
||||
ctx.db.order.aggregate({ _sum: { totalPrice: true }, where: { client: where, paymentStatus: 'PAID', paidAt: { gte: lo, lt: hi } } }),
|
||||
ctx.db.subscription.aggregate({ _sum: { amount: true }, where: { client: where, source: 'PAID', createdAt: { gte: lo, lt: hi } } }),
|
||||
]);
|
||||
revenue += (orders._sum.totalPrice ?? 0) + (subs._sum.amount ?? 0);
|
||||
}
|
||||
const fromVenues = venues * s['sales.bonusPerVenue'];
|
||||
const fromAgencies = agencies * s['sales.bonusPerAgency'];
|
||||
const fromRevenue = Math.round((revenue * s['sales.bonusAgencyPct']) / 100);
|
||||
return {
|
||||
venues,
|
||||
agencies,
|
||||
revenue,
|
||||
fromVenues,
|
||||
fromAgencies,
|
||||
fromRevenue,
|
||||
total: fromVenues + fromAgencies + fromRevenue,
|
||||
rules: { perVenue: s['sales.bonusPerVenue'], perAgency: s['sales.bonusPerAgency'], pct: s['sales.bonusAgencyPct'], months },
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user