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
@@ -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');
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1272,7 +1272,11 @@
|
||||
"transfer_standardPrice": "Տրանսֆեր «Ստանդարտ», ֏",
|
||||
"transfer_premiumPrice": "Տրանսֆեր «Պրեմիում», ֏",
|
||||
"assign_auto": "Պատվերների ավտոմատ բաշխում",
|
||||
"assign_maxAutoOffers": "Քանի կատարող փորձել ավտոմատ"
|
||||
"assign_maxAutoOffers": "Քանի կատարող փորձել ավտոմատ",
|
||||
"sales_bonusPerVenue": "Վաճառողի բոնուս հաստատության համար, ֏",
|
||||
"sales_bonusPerAgency": "Վաճառողի բոնուս գործակալության համար, ֏",
|
||||
"sales_bonusAgencyPct": "Վաճառողի բոնուս՝ % գործակալությունների հաճախորդների եկամտից",
|
||||
"sales_bonusAgencyMonths": "…քանի ամիս գործակալության միանալուց հետո"
|
||||
},
|
||||
"presets": {
|
||||
"add": "Ավելացնել նախադրվածք",
|
||||
|
||||
@@ -1272,7 +1272,11 @@
|
||||
"transfer_standardPrice": "Трансфер «Стандарт», ֏",
|
||||
"transfer_premiumPrice": "Трансфер «Премиум», ֏",
|
||||
"assign_auto": "Автораспределение заказов",
|
||||
"assign_maxAutoOffers": "Сколько исполнителей пробовать автоматически"
|
||||
"assign_maxAutoOffers": "Сколько исполнителей пробовать автоматически",
|
||||
"sales_bonusPerVenue": "Бонус продажника за заведение, ֏",
|
||||
"sales_bonusPerAgency": "Бонус продажника за агентство, ֏",
|
||||
"sales_bonusAgencyPct": "Бонус продажника: % выручки клиентов его агентств",
|
||||
"sales_bonusAgencyMonths": "…сколько месяцев после подключения агентства"
|
||||
},
|
||||
"presets": {
|
||||
"add": "Добавить пресет",
|
||||
|
||||
@@ -1272,7 +1272,11 @@
|
||||
"transfer_standardPrice": "接机「标准」,֏",
|
||||
"transfer_premiumPrice": "接机「高级」,֏",
|
||||
"assign_auto": "自动分配订单",
|
||||
"assign_maxAutoOffers": "自动尝试的服务人员数量"
|
||||
"assign_maxAutoOffers": "自动尝试的服务人员数量",
|
||||
"sales_bonusPerVenue": "每个商户的销售奖金,֏",
|
||||
"sales_bonusPerAgency": "每个代理的销售奖金,֏",
|
||||
"sales_bonusAgencyPct": "销售奖金:其代理客户收入的百分比",
|
||||
"sales_bonusAgencyMonths": "……代理加入后的月数"
|
||||
},
|
||||
"presets": {
|
||||
"add": "添加方案",
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user