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

@@ -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])
}