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

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