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

+8
View File
@@ -45,6 +45,14 @@ export const settingsSchema = z.object({
'assign.auto': z.boolean().default(true),
/** сколько исполнителей подряд система пробует сама, дальше заказ ждёт диспетчера */
'assign.maxAutoOffers': z.number().int().min(1).max(20).default(5),
/** бонус продажника: за каждое подключённое заведение, ֏ (0 — не начисляется; задаёт владелец) */
'sales.bonusPerVenue': z.number().int().min(0).default(0),
/** бонус продажника: за каждое подключённое агентство, ֏ */
'sales.bonusPerAgency': z.number().int().min(0).default(0),
/** бонус продажника: % от выручки с клиентов его агентств (оплаченные заказы и подписки) */
'sales.bonusAgencyPct': z.number().int().min(0).max(100).default(0),
/** …сколько месяцев после подключения агентства этот % начисляется */
'sales.bonusAgencyMonths': z.number().int().min(1).max(60).default(6),
/** MSG-1: чат закрывается через N часов после завершения (Этап 2) */
'chat.closeAfterHours': z.number().int().min(0).default(24),
/** раздел 6: автоудаление сканов через N дней после завершения */
@@ -0,0 +1,140 @@
-- CreateEnum
CREATE TYPE "SalesLeadKind" AS ENUM ('VENUE', 'AGENCY');
-- CreateEnum
CREATE TYPE "SalesStageKind" AS ENUM ('OPEN', 'WON', 'LOST');
-- AlterTable
ALTER TABLE "Agency" ADD COLUMN "note" TEXT,
ADD COLUMN "salesId" TEXT;
-- AlterTable
ALTER TABLE "Partner" ADD COLUMN "lat" DOUBLE PRECISION,
ADD COLUMN "lng" DOUBLE PRECISION,
ADD COLUMN "note" TEXT,
ADD COLUMN "phone" TEXT,
ADD COLUMN "salesId" TEXT;
-- AlterTable
ALTER TABLE "Payout" ADD COLUMN "agencyId" TEXT;
-- CreateTable
CREATE TABLE "SalesStage" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kind" "SalesStageKind" NOT NULL DEFAULT 'OPEN',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"color" TEXT NOT NULL DEFAULT 'blue',
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SalesStage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SalesLead" (
"id" TEXT NOT NULL,
"kind" "SalesLeadKind" NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT,
"contactName" TEXT,
"phone" TEXT,
"telegram" TEXT,
"address" TEXT,
"lat" DOUBLE PRECISION,
"lng" DOUBLE PRECISION,
"stageId" TEXT NOT NULL,
"stageChangedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ownerId" TEXT,
"note" TEXT,
"lostReason" TEXT,
"partnerId" TEXT,
"agencyId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SalesLead_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SalesActivity" (
"id" TEXT NOT NULL,
"leadId" TEXT NOT NULL,
"authorId" TEXT,
"type" TEXT NOT NULL,
"text" TEXT,
"fileKey" TEXT,
"fileName" TEXT,
"mime" TEXT,
"size" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SalesActivity_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SalesTask" (
"id" TEXT NOT NULL,
"leadId" TEXT,
"assigneeId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"dueAt" TIMESTAMP(3) NOT NULL,
"doneAt" TIMESTAMP(3),
"remindedAt" TIMESTAMP(3),
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SalesTask_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SalesLead_ownerId_stageId_idx" ON "SalesLead"("ownerId", "stageId");
-- CreateIndex
CREATE INDEX "SalesActivity_leadId_createdAt_idx" ON "SalesActivity"("leadId", "createdAt");
-- CreateIndex
CREATE INDEX "SalesTask_assigneeId_doneAt_dueAt_idx" ON "SalesTask"("assigneeId", "doneAt", "dueAt");
-- CreateIndex
CREATE INDEX "Payout_agencyId_createdAt_idx" ON "Payout"("agencyId", "createdAt");
-- AddForeignKey
ALTER TABLE "Agency" ADD CONSTRAINT "Agency_salesId_fkey" FOREIGN KEY ("salesId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Partner" ADD CONSTRAINT "Partner_salesId_fkey" FOREIGN KEY ("salesId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_agencyId_fkey" FOREIGN KEY ("agencyId") REFERENCES "Agency"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesLead" ADD CONSTRAINT "SalesLead_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "SalesStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesLead" ADD CONSTRAINT "SalesLead_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesLead" ADD CONSTRAINT "SalesLead_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesLead" ADD CONSTRAINT "SalesLead_agencyId_fkey" FOREIGN KEY ("agencyId") REFERENCES "Agency"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesActivity" ADD CONSTRAINT "SalesActivity_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "SalesLead"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesTask" ADD CONSTRAINT "SalesTask_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "SalesLead"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesTask" ADD CONSTRAINT "SalesTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "AdminUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Воронка по умолчанию (по ТЗ): Новый → Контакт → Встреча → Переговоры → Подключён / Отказ; суперадмин меняет в CRM
INSERT INTO "SalesStage" ("id", "name", "kind", "sortOrder", "color") VALUES
('stg_new', 'Новый', 'OPEN', 0, 'blue'),
('stg_contact', 'Контакт', 'OPEN', 1, 'learn'),
('stg_meeting', 'Встреча', 'OPEN', 2, 'docs'),
('stg_talks', 'Переговоры', 'OPEN', 3, 'perks'),
('stg_won', 'Подключён', 'WON', 4, 'home'),
('stg_lost', 'Отказ', 'LOST', 5, 'pink');
+123
View File
@@ -664,12 +664,19 @@ model Agency {
commissionPct Int @default(10)
contact String?
active Boolean @default(true)
/// продажник, который подключил агентство (бонус в CRM)
salesId String?
sales AdminUser? @relation("AgencySales", fields: [salesId], references: [id])
/// заметка команды
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clients Client[]
commissions AgencyCommission[]
promoCodes PromoCode[]
payouts Payout[]
leads SalesLead[]
}
model AgencyCommission {
@@ -755,6 +762,14 @@ model Partner {
offer Json
terms Json?
address String?
/// точка на карте заведений
lat Float?
lng Float?
phone String?
/// продажник, который подключил заведение (бонус в CRM)
salesId String?
sales AdminUser? @relation("PartnerSales", fields: [salesId], references: [id])
note String?
active Boolean @default(true)
/// одноразовая ссылка для собственника: t.me/<рабочий бот>?start=po-<код> (выдаёт админка)
ownerInviteCode String? @unique
@@ -764,6 +779,7 @@ model Partner {
offers PartnerOffer[]
members PartnerMember[]
invites PartnerInvite[]
leads SalesLead[]
}
enum OfferLimit {
@@ -850,6 +866,9 @@ model Payout {
provider Provider? @relation(fields: [providerId], references: [id])
companyId String?
company ProviderCompany? @relation(fields: [companyId], references: [id])
/// вознаграждение агентству
agencyId String?
agency Agency? @relation(fields: [agencyId], references: [id])
periodStart DateTime
periodEnd DateTime
amount Int
@@ -861,6 +880,7 @@ model Payout {
createdAt DateTime @default(now())
@@index([providerId, createdAt])
@@index([agencyId, createdAt])
}
// ───────────── Админка ─────────────
@@ -891,6 +911,10 @@ model AdminUser {
segments SavedSegment[]
chatThreads ChatThread[]
chatTemplates ChatTemplate[]
salesAgencies Agency[] @relation("AgencySales")
salesPartners Partner[] @relation("PartnerSales")
salesLeads SalesLead[] @relation("LeadOwner")
salesTasks SalesTask[] @relation("TaskAssignee")
}
/// Вход в новую админку через Telegram: страница показывает ссылку t.me/<рабочий бот>?start=lg-<code>,
@@ -1246,3 +1270,102 @@ model ChatTemplate {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ───────────── CRM продажника (этап 4) ─────────────
enum SalesLeadKind {
/// кафе, ресторан, магазин… — станет партнёром клубной карты
VENUE
/// агентство недвижимости / релокации — будет приводить клиентов
AGENCY
}
enum SalesStageKind {
OPEN
/// подключён — лид становится партнёром или агентством
WON
LOST
}
/// Этап воронки; набор и порядок настраивает суперадмин
model SalesStage {
id String @id @default(cuid())
name String
kind SalesStageKind @default(OPEN)
sortOrder Int @default(0)
/// пастельный цвет колонки: pink | blue | home | learn | docs | perks
color String @default("blue")
active Boolean @default(true)
createdAt DateTime @default(now())
leads SalesLead[]
}
model SalesLead {
id String @id @default(cuid())
kind SalesLeadKind
name String
/// для заведений: cafe | restaurant | shop | beauty | other
category String?
contactName String?
phone String?
telegram String?
address String?
lat Float?
lng Float?
stageId String
stage SalesStage @relation(fields: [stageId], references: [id])
stageChangedAt DateTime @default(now())
/// продажник, который ведёт лид
ownerId String?
owner AdminUser? @relation("LeadOwner", fields: [ownerId], references: [id])
note String?
lostReason String?
/// во что превратился лид после «Подключён»
partnerId String?
partner Partner? @relation(fields: [partnerId], references: [id])
agencyId String?
agency Agency? @relation(fields: [agencyId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
activities SalesActivity[]
tasks SalesTask[]
@@index([ownerId, stageId])
}
/// История контактов: звонок, встреча, заметка, фото/файл, смена этапа
model SalesActivity {
id String @id @default(cuid())
leadId String
lead SalesLead @relation(fields: [leadId], references: [id], onDelete: Cascade)
authorId String?
/// CALL | MEETING | NOTE | FILE | STAGE | WON
type String
text String?
fileKey String?
fileName String?
mime String?
size Int?
createdAt DateTime @default(now())
@@index([leadId, createdAt])
}
/// Задача продажника с напоминанием в Telegram (рабочий бот) в срок
model SalesTask {
id String @id @default(cuid())
leadId String?
lead SalesLead? @relation(fields: [leadId], references: [id], onDelete: Cascade)
assigneeId String
assignee AdminUser @relation("TaskAssignee", fields: [assigneeId], references: [id])
title String
dueAt DateTime
doneAt DateTime?
remindedAt DateTime?
createdById String?
createdAt DateTime @default(now())
@@index([assigneeId, doneAt, dueAt])
}
+5 -1
View File
@@ -1272,7 +1272,11 @@
"transfer_standardPrice": "Transfer “Standard”, AMD",
"transfer_premiumPrice": "Transfer “Premium”, AMD",
"assign_auto": "Assign orders automatically",
"assign_maxAutoOffers": "How many providers to try automatically"
"assign_maxAutoOffers": "How many providers to try automatically",
"sales_bonusPerVenue": "Sales bonus per venue, ֏",
"sales_bonusPerAgency": "Sales bonus per agency, ֏",
"sales_bonusAgencyPct": "Sales bonus: % of revenue from their agencies’ clients",
"sales_bonusAgencyMonths": "…for how many months after the agency joins"
},
"presets": {
"add": "Add preset",
+5 -1
View File
@@ -1272,7 +1272,11 @@
"transfer_standardPrice": "Traslado «Estándar», AMD",
"transfer_premiumPrice": "Traslado «Premium», AMD",
"assign_auto": "Asignar pedidos automáticamente",
"assign_maxAutoOffers": "Cuántos profesionales probar automáticamente"
"assign_maxAutoOffers": "Cuántos profesionales probar automáticamente",
"sales_bonusPerVenue": "Bono de ventas por local, ֏",
"sales_bonusPerAgency": "Bono de ventas por agencia, ֏",
"sales_bonusAgencyPct": "Bono de ventas: % de ingresos de clientes de sus agencias",
"sales_bonusAgencyMonths": "…durante cuántos meses tras unirse la agencia"
},
"presets": {
"add": "Añadir plantilla",
+5 -1
View File
@@ -1272,7 +1272,11 @@
"transfer_standardPrice": "Տրանսֆեր «Ստանդարտ», ֏",
"transfer_premiumPrice": "Տրանսֆեր «Պրեմիում», ֏",
"assign_auto": "Պատվերների ավտոմատ բաշխում",
"assign_maxAutoOffers": "Քանի կատարող փորձել ավտոմատ"
"assign_maxAutoOffers": "Քանի կատարող փորձել ավտոմատ",
"sales_bonusPerVenue": "Վաճառողի բոնուս հաստատության համար, ֏",
"sales_bonusPerAgency": "Վաճառողի բոնուս գործակալության համար, ֏",
"sales_bonusAgencyPct": "Վաճառողի բոնուս՝ % գործակալությունների հաճախորդների եկամտից",
"sales_bonusAgencyMonths": "…քանի ամիս գործակալության միանալուց հետո"
},
"presets": {
"add": "Ավելացնել նախադրվածք",
+5 -1
View File
@@ -1272,7 +1272,11 @@
"transfer_standardPrice": "Трансфер «Стандарт», ֏",
"transfer_premiumPrice": "Трансфер «Премиум», ֏",
"assign_auto": "Автораспределение заказов",
"assign_maxAutoOffers": "Сколько исполнителей пробовать автоматически"
"assign_maxAutoOffers": "Сколько исполнителей пробовать автоматически",
"sales_bonusPerVenue": "Бонус продажника за заведение, ֏",
"sales_bonusPerAgency": "Бонус продажника за агентство, ֏",
"sales_bonusAgencyPct": "Бонус продажника: % выручки клиентов его агентств",
"sales_bonusAgencyMonths": "…сколько месяцев после подключения агентства"
},
"presets": {
"add": "Добавить пресет",
+5 -1
View File
@@ -1272,7 +1272,11 @@
"transfer_standardPrice": "接机「标准」,֏",
"transfer_premiumPrice": "接机「高级」,֏",
"assign_auto": "自动分配订单",
"assign_maxAutoOffers": "自动尝试的服务人员数量"
"assign_maxAutoOffers": "自动尝试的服务人员数量",
"sales_bonusPerVenue": "每个商户的销售奖金,֏",
"sales_bonusPerAgency": "每个代理的销售奖金,֏",
"sales_bonusAgencyPct": "销售奖金:其代理客户收入的百分比",
"sales_bonusAgencyMonths": "……代理加入后的月数"
},
"presets": {
"add": "添加方案",
+8 -1
View File
@@ -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';
+1
View File
@@ -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';
+139
View File
@@ -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 },
};
}
+92
View File
@@ -0,0 +1,92 @@
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 });
});
});