Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1743 lines
59 KiB
Plaintext
1743 lines
59 KiB
Plaintext
// Barev Club — модель данных (PRD, раздел 7).
|
||
// Схема заложена сразу целиком; часть сущностей используется начиная с Этапов 2–3.
|
||
|
||
generator client {
|
||
provider = "prisma-client"
|
||
output = "../src/generated/prisma"
|
||
moduleFormat = "esm"
|
||
}
|
||
|
||
datasource db {
|
||
provider = "postgresql"
|
||
}
|
||
|
||
// ───────────── Справочные перечисления ─────────────
|
||
|
||
enum Locale {
|
||
ru
|
||
en
|
||
zh
|
||
hy
|
||
es
|
||
}
|
||
|
||
/// Направление (раздел 2)
|
||
enum CategoryCode {
|
||
HOME
|
||
DOCS
|
||
LEARNING
|
||
PERKS
|
||
}
|
||
|
||
/// Сценарий заказа — определяет state machine и шаги исполнителя
|
||
enum ServiceFlow {
|
||
CLEANING
|
||
REPAIR
|
||
TRANSFER
|
||
LANGUAGE
|
||
CAREER
|
||
DOCUMENTS
|
||
}
|
||
|
||
enum ProviderRole {
|
||
CLEANER
|
||
HANDYMAN
|
||
DRIVER
|
||
TEACHER
|
||
CAREER_CONSULTANT
|
||
DOCS_AGENT
|
||
}
|
||
|
||
/// Все статусы всех сценариев. Допустимые переходы — в packages/core (flows).
|
||
enum OrderStatus {
|
||
NEW
|
||
ASSIGNED
|
||
EN_ROUTE
|
||
CLEANING
|
||
ON_SITE
|
||
ESTIMATE_APPROVED
|
||
REPAIRING
|
||
DRIVER_WAITING
|
||
RIDE_IN_PROGRESS
|
||
ENROLLED
|
||
LESSON_SCHEDULED
|
||
LESSON_IN_PROGRESS
|
||
LESSON_DONE
|
||
CONSULTANT_ASSIGNED
|
||
RESUME_RECEIVED
|
||
MEETING
|
||
PLAN_SENT
|
||
DOCS_REVIEW
|
||
NEEDS_MORE_DOCS
|
||
SUBMITTED
|
||
DONE
|
||
COMPLETED
|
||
CANCELLED
|
||
}
|
||
|
||
enum ActorType {
|
||
CLIENT
|
||
PROVIDER
|
||
DISPATCHER
|
||
ADMIN
|
||
SYSTEM
|
||
}
|
||
|
||
enum OfferStatus {
|
||
OFFERED
|
||
ACCEPTED
|
||
DECLINED
|
||
EXPIRED
|
||
REVOKED
|
||
}
|
||
|
||
enum PaymentStatus {
|
||
UNPAID
|
||
PAID
|
||
REFUNDED
|
||
}
|
||
|
||
/// Модель сотрудничества с партнёром услуг
|
||
enum PartnerDeal {
|
||
/// расходы наши: платим ФОТ и налог, партнёру — % от прибыли
|
||
PROFIT_SHARE
|
||
/// как Uber: забираем свой % с заказа, остальное партнёру, расходы — его
|
||
COMMISSION
|
||
}
|
||
|
||
enum RateType {
|
||
FIXED
|
||
PERCENT
|
||
}
|
||
|
||
enum AdminRole {
|
||
OWNER
|
||
OPERATOR
|
||
}
|
||
|
||
/// Роли новой админки (apps/backoffice). У одного человека может быть несколько.
|
||
enum StaffRole {
|
||
SUPERADMIN
|
||
SUPPORT
|
||
CONCIERGE
|
||
SALES
|
||
}
|
||
|
||
enum SubscriptionPlan {
|
||
BAREV_PLUS
|
||
ELITE
|
||
}
|
||
|
||
enum SubscriptionSource {
|
||
AGENCY_GIFT
|
||
MANUAL
|
||
PAID
|
||
}
|
||
|
||
enum SubscriptionStatus {
|
||
ACTIVE
|
||
EXPIRED
|
||
CANCELLED
|
||
}
|
||
|
||
enum PromoKind {
|
||
AGENCY_GIFT
|
||
DISCOUNT
|
||
}
|
||
|
||
enum FileKind {
|
||
PHOTO_REPORT
|
||
CLIENT_PHOTO
|
||
DOCUMENT_SCAN
|
||
RESUME
|
||
OTHER
|
||
}
|
||
|
||
enum EstimateStatus {
|
||
PENDING
|
||
ACCEPTED
|
||
REJECTED
|
||
}
|
||
|
||
enum TaskStatus {
|
||
OPEN
|
||
DONE
|
||
}
|
||
|
||
/// Заявка с сайта: NEW — человек ещё не открыл бота; LINKED — открыл, но заказ не создан (нужен звонок оператора);
|
||
/// ORDERED — стала заказом; CANCELLED — отменена оператором
|
||
enum WebRequestStatus {
|
||
NEW
|
||
LINKED
|
||
ORDERED
|
||
CANCELLED
|
||
}
|
||
|
||
enum PayoutStatus {
|
||
DRAFT
|
||
APPROVED
|
||
PAID
|
||
}
|
||
|
||
// ───────────── Пользователи ─────────────
|
||
|
||
/// Пользователь Telegram. Один и тот же человек может быть клиентом и исполнителем.
|
||
model User {
|
||
id String @id @default(cuid())
|
||
telegramId BigInt @unique
|
||
username String?
|
||
firstName String?
|
||
lastName String?
|
||
phone String?
|
||
locale Locale @default(ru)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
client Client?
|
||
provider Provider?
|
||
dispatcher CompanyDispatcher[]
|
||
partnerMembers PartnerMember[]
|
||
messages Message[]
|
||
proWebSessions ProWebSession[]
|
||
}
|
||
|
||
model Client {
|
||
id String @id @default(cuid())
|
||
userId String @unique
|
||
user User @relation(fields: [userId], references: [id])
|
||
consentAt DateTime?
|
||
consentVersion String?
|
||
agencyId String?
|
||
agency Agency? @relation(fields: [agencyId], references: [id])
|
||
agencyAttachedAt DateTime?
|
||
/// источник: deeplink | manual_code | organic
|
||
source String @default("organic")
|
||
utm Json?
|
||
/// подарочный месяц уже использован (один раз на клиента)
|
||
giftUsedAt DateTime?
|
||
/// короткий код клубной карты (Этап 3)
|
||
cardCode String @unique
|
||
/// сценарий приложения: переезжает или уже живёт в Армении; null — ещё не выбрал
|
||
segment ClientSegment?
|
||
/// метки команды: VIP, проблемный…
|
||
tags String[] @default([])
|
||
/// заметка команды о клиенте (клиент не видит)
|
||
adminNote String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
orders Order[]
|
||
subscriptions Subscription[]
|
||
ratings Rating[]
|
||
cardUsages CardUsage[]
|
||
promoUses PromoCodeUse[]
|
||
webRequests WebRequest[]
|
||
leads Lead[]
|
||
requests ClientRequest[]
|
||
concierge ConciergeMessage[]
|
||
chatThreads ChatThread[]
|
||
/// куда сейчас пишет клиент: SUPPORT или CONCIERGE (переключается командой бота или командой поддержки);
|
||
/// null — по умолчанию: подписчик → консьерж, остальные → поддержка
|
||
chatChannel ChatChannel?
|
||
|
||
// ── барики (кэшбек) — кэш баланса; источник правды — журнал PointsAccrual / PointsRedemption ──
|
||
/// доступно: остатки активных начислений минус долг
|
||
pointsActive Int @default(0)
|
||
/// в ожидании (станут доступны позже)
|
||
pointsPending Int @default(0)
|
||
/// долг после отмены уже потраченного начисления; гасится следующими начислениями
|
||
pointsDebt Int @default(0)
|
||
/// последнее начисление или списание — от неё считается сгорание
|
||
pointsActivityAt DateTime?
|
||
/// предупреждения о сгорании уже отправлены (сбрасываются при активности)
|
||
pointsWarn14At DateTime?
|
||
pointsWarn3At DateTime?
|
||
/// серия уборок: сколько подряд с перерывами не больше N дней
|
||
streakCount Int @default(0)
|
||
streakLastAt DateTime?
|
||
pointsAccruals PointsAccrual[]
|
||
pointsRedemptions PointsRedemption[]
|
||
pointsSuggestions PointsSuggestion[]
|
||
|
||
@@index([agencyId])
|
||
}
|
||
|
||
// ───────────── Исполнители ─────────────
|
||
|
||
model ProviderCompany {
|
||
id String @id @default(cuid())
|
||
name String
|
||
/// какие роли исполнителей есть в компании (какие услуги она берёт)
|
||
roles ProviderRole[]
|
||
/// договорная ставка компании
|
||
rateType RateType @default(PERCENT)
|
||
rateValue Int @default(70)
|
||
contactPhone String?
|
||
/// ссылка менеджера: t.me/<рабочий бот>?start=<inviteCode>
|
||
inviteCode String @unique
|
||
active Boolean @default(true)
|
||
/// модель сотрудничества (rateType/rateValue — устаревшая ставка, не используется)
|
||
deal PartnerDeal @default(COMMISSION)
|
||
/// COMMISSION: наш % с оплаченной суммы заказа
|
||
commissionPct Int @default(30)
|
||
/// PROFIT_SHARE: % партнёра от (оплачено − ФОТ − налог)
|
||
profitSharePct Int @default(50)
|
||
/// PROFIT_SHARE: свой ФОТ клинера, % от заказа; пусто — из настроек
|
||
fotPct Int?
|
||
/// PROFIT_SHARE: свой налог, %; пусто — из настроек
|
||
taxPct Int?
|
||
/// менеджер включил автораспределение по сотрудникам
|
||
autoAssign Boolean @default(false)
|
||
/// перерыв между заказами сотрудника на дорогу, мин (не меньше минимума из настроек)
|
||
travelGapMinutes Int @default(60)
|
||
note String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
earnings PartnerEarning[]
|
||
employees Provider[]
|
||
dispatchers CompanyDispatcher[]
|
||
orders Order[]
|
||
offers OrderOffer[]
|
||
payouts Payout[]
|
||
}
|
||
|
||
model CompanyDispatcher {
|
||
id String @id @default(cuid())
|
||
companyId String
|
||
company ProviderCompany @relation(fields: [companyId], references: [id])
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id])
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([companyId, userId])
|
||
}
|
||
|
||
model Provider {
|
||
id String @id @default(cuid())
|
||
/// заполняется, когда исполнитель открыл бота по инвайт-коду
|
||
userId String? @unique
|
||
user User? @relation(fields: [userId], references: [id])
|
||
name String
|
||
phone String?
|
||
role ProviderRole
|
||
companyId String?
|
||
company ProviderCompany? @relation(fields: [companyId], references: [id])
|
||
languages Locale[]
|
||
/// ставка фрилансера; для сотрудников компании не используется
|
||
rateType RateType @default(PERCENT)
|
||
rateValue Int @default(70)
|
||
ratingSum Int @default(0)
|
||
ratingCount Int @default(0)
|
||
onShift Boolean @default(false)
|
||
active Boolean @default(true)
|
||
inviteCode String @unique
|
||
/// пресет ставок (джун / мидл / прайват); если задан — заменяет rateType/rateValue
|
||
payoutPresetId String?
|
||
payoutPreset PayoutPreset? @relation(fields: [payoutPresetId], references: [id])
|
||
/// допуск: почему заблокирован и когда (active = false)
|
||
blockedReason String?
|
||
blockedAt DateTime?
|
||
/// заметка команды об исполнителе (он не видит)
|
||
adminNote String?
|
||
/// нагрузка (задаёт менеджер партнёра): максимум заказов и часов в день; пусто — без ограничения
|
||
maxOrdersPerDay Int?
|
||
maxHoursPerDay Int?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
districts ProviderDistrict[]
|
||
docs ProviderDoc[]
|
||
schedule ProviderSchedule[]
|
||
orders Order[]
|
||
offers OrderOffer[]
|
||
ratings Rating[]
|
||
payouts Payout[]
|
||
|
||
@@index([role, active])
|
||
concierge ConciergeMessage[]
|
||
chatThreads ChatThread[]
|
||
}
|
||
|
||
/// Документы исполнителя для допуска: паспорт, договор, справка… Файл — в приватном хранилище
|
||
model ProviderDoc {
|
||
id String @id @default(cuid())
|
||
providerId String
|
||
provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade)
|
||
title String
|
||
fileKey String
|
||
fileName String
|
||
mime String
|
||
size Int
|
||
uploadedById String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([providerId])
|
||
}
|
||
|
||
/// Пресет ставок исполнителя: для каждой строки цены — фикс или % (см. packages/core/src/payout.ts)
|
||
model PayoutPreset {
|
||
id String @id @default(cuid())
|
||
name String
|
||
role ProviderRole
|
||
rules Json
|
||
sortOrder Int @default(0)
|
||
active Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
providers Provider[]
|
||
}
|
||
|
||
model District {
|
||
id String @id @default(cuid())
|
||
code String @unique
|
||
sortOrder Int @default(0)
|
||
providers ProviderDistrict[]
|
||
orders Order[]
|
||
webRequests WebRequest[]
|
||
}
|
||
|
||
model ProviderDistrict {
|
||
providerId String
|
||
provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade)
|
||
districtId String
|
||
district District @relation(fields: [districtId], references: [id], onDelete: Cascade)
|
||
|
||
@@id([providerId, districtId])
|
||
}
|
||
|
||
/// Недельный график: 0 = понедельник … 6 = воскресенье, минуты от полуночи по Asia/Yerevan
|
||
model ProviderSchedule {
|
||
id String @id @default(cuid())
|
||
providerId String
|
||
provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade)
|
||
weekday Int
|
||
startMinute Int
|
||
endMinute Int
|
||
|
||
@@index([providerId, weekday])
|
||
}
|
||
|
||
// ───────────── Каталог ─────────────
|
||
|
||
model Category {
|
||
id String @id @default(cuid())
|
||
code CategoryCode @unique
|
||
sortOrder Int @default(0)
|
||
/// скидка Barev+ на направление, %
|
||
barevPlusDiscountPct Int @default(0)
|
||
/// скидка Barev Elite на направление, %
|
||
eliteDiscountPct Int @default(0)
|
||
active Boolean @default(true)
|
||
services Service[]
|
||
}
|
||
|
||
model Service {
|
||
id String @id @default(cuid())
|
||
/// код услуги; тексты берутся из i18n по ключу services.<code>.*
|
||
code String @unique
|
||
categoryId String
|
||
category Category @relation(fields: [categoryId], references: [id])
|
||
/// категория кэшбека «Барики» (ставки Barev / Barev+ / Elite)
|
||
pointsCategoryId String?
|
||
pointsCategory PointsCategory? @relation(fields: [pointsCategoryId], references: [id])
|
||
flow ServiceFlow
|
||
providerRole ProviderRole
|
||
/// правила цены (см. packages/core/src/pricing)
|
||
pricing Json
|
||
/// описание параметров формы заказа (см. packages/core/src/params)
|
||
paramsSchema Json
|
||
/// окно записи и шаг слотов (см. packages/core/src/slots)
|
||
slotConfig Json
|
||
/// удержание при поздней отмене, %
|
||
lateCancelFeePct Int @default(30)
|
||
isOnline Boolean @default(false)
|
||
active Boolean @default(true)
|
||
sortOrder Int @default(0)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
checklist ChecklistItem[]
|
||
orders Order[]
|
||
webRequests WebRequest[]
|
||
}
|
||
|
||
/// Шаблон пункта чек-листа услуги
|
||
model ChecklistItem {
|
||
id String @id @default(cuid())
|
||
serviceId String
|
||
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
|
||
/// код пункта; подпись — i18n checklist.<code>
|
||
code String
|
||
requiresPhoto Boolean @default(false)
|
||
sortOrder Int @default(0)
|
||
|
||
@@unique([serviceId, code])
|
||
}
|
||
|
||
model PaymentMethod {
|
||
id String @id @default(cuid())
|
||
code String @unique
|
||
/// { ru, en, zh, hy, es }
|
||
name Json
|
||
active Boolean @default(true)
|
||
sortOrder Int @default(0)
|
||
orders Order[]
|
||
}
|
||
|
||
// ───────────── Заказы ─────────────
|
||
|
||
model Order {
|
||
id String @id @default(cuid())
|
||
number Int @unique @default(autoincrement())
|
||
clientId String
|
||
client Client @relation(fields: [clientId], references: [id])
|
||
serviceId String
|
||
service Service @relation(fields: [serviceId], references: [id])
|
||
params Json
|
||
scheduledAt DateTime
|
||
durationMinutes Int
|
||
isOnline Boolean @default(false)
|
||
addressText String?
|
||
districtId String?
|
||
district District? @relation(fields: [districtId], references: [id])
|
||
lat Float?
|
||
lng Float?
|
||
note String?
|
||
/// язык клиента на момент заказа
|
||
locale Locale
|
||
basePrice Int
|
||
/// разбивка цены на момент заказа (основа, комнаты, санузлы, доп. услуги) — для расчёта выплат
|
||
priceBreakdown Json?
|
||
discountPct Int @default(0)
|
||
discountAmount Int @default(0)
|
||
totalPrice Int
|
||
/// оплачено бариками (к оплате деньгами: totalPrice − pointsUsed)
|
||
pointsUsed Int @default(0)
|
||
status OrderStatus @default(NEW)
|
||
providerId String?
|
||
provider Provider? @relation(fields: [providerId], references: [id])
|
||
/// заказ передан компании; пока providerId пуст — «Назначен на компанию»
|
||
companyId String?
|
||
company ProviderCompany? @relation(fields: [companyId], references: [id])
|
||
paymentMethodId String?
|
||
paymentMethod PaymentMethod? @relation(fields: [paymentMethodId], references: [id])
|
||
paymentStatus PaymentStatus @default(UNPAID)
|
||
paidAt DateTime?
|
||
cancelledAt DateTime?
|
||
cancelFee Int @default(0)
|
||
cancelReason String?
|
||
completedAt DateTime?
|
||
repeatOfId String?
|
||
reminded24hAt DateTime?
|
||
reminded1hAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
history OrderStatusHistory[]
|
||
checklist OrderChecklistItem[]
|
||
offers OrderOffer[]
|
||
files OrderFile[]
|
||
messages Message[]
|
||
chatMessages ConciergeMessage[]
|
||
estimates Estimate[]
|
||
rating Rating?
|
||
lessons LessonPackage?
|
||
payments Payment[]
|
||
commission AgencyCommission?
|
||
partnerEarning PartnerEarning?
|
||
pointsAccruals PointsAccrual[]
|
||
pointsRedemptions PointsRedemption[]
|
||
tasks OperatorTask[]
|
||
webRequest WebRequest?
|
||
|
||
@@index([status])
|
||
@@index([scheduledAt])
|
||
@@index([clientId])
|
||
@@index([providerId, scheduledAt])
|
||
}
|
||
|
||
model OrderStatusHistory {
|
||
id String @id @default(cuid())
|
||
orderId String
|
||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
fromStatus OrderStatus?
|
||
toStatus OrderStatus
|
||
/// событие без смены статуса (например «Я на месте») или код действия
|
||
event String?
|
||
actorType ActorType
|
||
actorId String?
|
||
comment String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([orderId, createdAt])
|
||
}
|
||
|
||
/// Предложение заказа исполнителю или компании (принять / отклонить с таймаутом)
|
||
model OrderOffer {
|
||
id String @id @default(cuid())
|
||
orderId String
|
||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
providerId String?
|
||
provider Provider? @relation(fields: [providerId], references: [id])
|
||
companyId String?
|
||
company ProviderCompany? @relation(fields: [companyId], references: [id])
|
||
status OfferStatus @default(OFFERED)
|
||
expiresAt DateTime?
|
||
respondedAt DateTime?
|
||
createdById String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([orderId, status])
|
||
@@index([providerId, status])
|
||
@@index([companyId, status])
|
||
}
|
||
|
||
model OrderChecklistItem {
|
||
id String @id @default(cuid())
|
||
orderId String
|
||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
code String
|
||
requiresPhoto Boolean @default(false)
|
||
sortOrder Int @default(0)
|
||
checkedAt DateTime?
|
||
fileId String? @unique
|
||
file OrderFile? @relation(fields: [fileId], references: [id])
|
||
/// пункт отметила команда за исполнителя (id сотрудника админки)
|
||
checkedByAdminId String?
|
||
/// отмечено без фото — причина (команда)
|
||
photoWaivedReason String?
|
||
|
||
@@unique([orderId, code])
|
||
}
|
||
|
||
model OrderFile {
|
||
id String @id @default(cuid())
|
||
orderId String
|
||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
kind FileKind
|
||
storageKey String @unique
|
||
mime String
|
||
size Int
|
||
uploadedBy ActorType
|
||
uploadedById String?
|
||
/// дата автоудаления (документы — через N дней после завершения)
|
||
deleteAfter DateTime?
|
||
deletedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
checklistItem OrderChecklistItem?
|
||
accessLog FileAccessLog[]
|
||
}
|
||
|
||
model FileAccessLog {
|
||
id String @id @default(cuid())
|
||
fileId String
|
||
file OrderFile @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||
actorType ActorType
|
||
actorId String?
|
||
createdAt DateTime @default(now())
|
||
}
|
||
|
||
/// Смета ремонта (Этап 2)
|
||
model Estimate {
|
||
id String @id @default(cuid())
|
||
orderId String
|
||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
items Json
|
||
total Int
|
||
status EstimateStatus @default(PENDING)
|
||
respondedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
}
|
||
|
||
/// Чат клиент ↔ исполнитель через бота (Этап 2)
|
||
model Message {
|
||
id String @id @default(cuid())
|
||
orderId String
|
||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
fromType ActorType
|
||
fromUserId String?
|
||
fromUser User? @relation(fields: [fromUserId], references: [id])
|
||
text String?
|
||
fileId String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([orderId, createdAt])
|
||
}
|
||
|
||
model Rating {
|
||
id String @id @default(cuid())
|
||
orderId String @unique
|
||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
clientId String
|
||
client Client @relation(fields: [clientId], references: [id])
|
||
providerId String?
|
||
provider Provider? @relation(fields: [providerId], references: [id])
|
||
score Int
|
||
comment String?
|
||
createdAt DateTime @default(now())
|
||
}
|
||
|
||
/// Пакет уроков (Этап 2)
|
||
model LessonPackage {
|
||
id String @id @default(cuid())
|
||
orderId String @unique
|
||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
total Int
|
||
completed Int @default(0)
|
||
}
|
||
|
||
model Payment {
|
||
id String @id @default(cuid())
|
||
orderId String
|
||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
/// manual | <будущий провайдер>
|
||
provider String
|
||
amount Int
|
||
status PaymentStatus
|
||
externalId String?
|
||
markedById String?
|
||
createdAt DateTime @default(now())
|
||
}
|
||
|
||
model OperatorTask {
|
||
id String @id @default(cuid())
|
||
type String
|
||
orderId String?
|
||
order Order? @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
status TaskStatus @default(OPEN)
|
||
note String?
|
||
createdAt DateTime @default(now())
|
||
doneAt DateTime?
|
||
}
|
||
|
||
// ───────────── Агентства, промокоды, членство ─────────────
|
||
|
||
model Agency {
|
||
id String @id @default(cuid())
|
||
/// код для deep-link и ручного ввода (латиница/цифры)
|
||
code String @unique
|
||
name String
|
||
/// вознаграждение, % от оплаченных заказов клиентов агентства
|
||
commissionPct Int @default(10)
|
||
/// подарок новым клиентам: уровень (пусто — без подарка) и срок в месяцах
|
||
giftPlan SubscriptionPlan? @default(BAREV_PLUS)
|
||
giftMonths Int @default(1)
|
||
/// барики на новоселье: сумма (0 — нет) и срок жизни в днях
|
||
housewarmingPoints Int @default(0)
|
||
housewarmingDays Int @default(60)
|
||
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 {
|
||
id String @id @default(cuid())
|
||
agencyId String
|
||
agency Agency @relation(fields: [agencyId], references: [id])
|
||
orderId String @unique
|
||
order Order @relation(fields: [orderId], references: [id])
|
||
pct Int
|
||
amount Int
|
||
/// YYYY-MM
|
||
period String
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([agencyId, period])
|
||
}
|
||
|
||
/// Доход партнёра услуг с выполненного заказа — фиксируется при завершении, чтобы смена условий не меняла прошлое
|
||
model PartnerEarning {
|
||
id String @id @default(cuid())
|
||
companyId String
|
||
company ProviderCompany @relation(fields: [companyId], references: [id])
|
||
orderId String @unique
|
||
order Order @relation(fields: [orderId], references: [id])
|
||
deal PartnerDeal
|
||
/// фактически оплачено клиентом
|
||
paid Int
|
||
/// PROFIT_SHARE: ФОТ и налог, которые несём мы
|
||
fot Int @default(0)
|
||
tax Int @default(0)
|
||
/// % партнёра (PROFIT_SHARE) или наша комиссия (COMMISSION)
|
||
pct Int
|
||
/// доход партнёра
|
||
amount Int
|
||
/// YYYY-MM
|
||
period String
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([companyId, period])
|
||
}
|
||
|
||
model PromoCode {
|
||
id String @id @default(cuid())
|
||
code String @unique
|
||
kind PromoKind
|
||
agencyId String?
|
||
agency Agency? @relation(fields: [agencyId], references: [id])
|
||
discountPct Int?
|
||
maxUses Int?
|
||
usedCount Int @default(0)
|
||
validFrom DateTime?
|
||
validUntil DateTime?
|
||
active Boolean @default(true)
|
||
/// для команды: откуда код (рассылка, блогер, партнёр)
|
||
note String?
|
||
/// на какие направления действует; пусто — на все
|
||
categories CategoryCode[] @default([])
|
||
createdAt DateTime @default(now())
|
||
|
||
uses PromoCodeUse[]
|
||
}
|
||
|
||
model PromoCodeUse {
|
||
id String @id @default(cuid())
|
||
promoId String
|
||
promo PromoCode @relation(fields: [promoId], references: [id])
|
||
clientId String
|
||
client Client @relation(fields: [clientId], references: [id])
|
||
orderId String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([promoId, clientId])
|
||
}
|
||
|
||
model Subscription {
|
||
id String @id @default(cuid())
|
||
clientId String
|
||
client Client @relation(fields: [clientId], references: [id])
|
||
plan SubscriptionPlan @default(BAREV_PLUS)
|
||
source SubscriptionSource
|
||
status SubscriptionStatus @default(ACTIVE)
|
||
startsAt DateTime
|
||
endsAt DateTime
|
||
remindedAt DateTime?
|
||
/// оплачено клиентом, AMD (PAID); для подарков пусто
|
||
amount Int?
|
||
/// cash | transfer | card | link
|
||
payMethod String?
|
||
/// кто из команды начислил / продал
|
||
grantedByAdminId String?
|
||
/// агентство, подарившее подписку (AGENCY_GIFT)
|
||
agencyId String?
|
||
note String?
|
||
cancelledAt DateTime?
|
||
refundAmount Int?
|
||
refundedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([clientId, endsAt])
|
||
@@index([createdAt])
|
||
}
|
||
|
||
// ───────────── Партнёры и клубная карта (Этап 3) ─────────────
|
||
|
||
model Partner {
|
||
id String @id @default(cuid())
|
||
/// cafe | restaurant | other
|
||
category String
|
||
name String
|
||
/// { ru, en, ... }
|
||
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
|
||
createdAt DateTime @default(now())
|
||
|
||
usages CardUsage[]
|
||
offers PartnerOffer[]
|
||
members PartnerMember[]
|
||
invites PartnerInvite[]
|
||
leads SalesLead[]
|
||
}
|
||
|
||
enum OfferLimit {
|
||
/// без ограничений
|
||
NONE
|
||
/// только первое посещение
|
||
ONCE
|
||
/// N раз в календарную неделю
|
||
WEEK
|
||
/// N раз в календарный месяц
|
||
MONTH
|
||
}
|
||
|
||
/// Предложение партнёра: что положено клиенту с определённым уровнем и как часто
|
||
model PartnerOffer {
|
||
id String @id @default(cuid())
|
||
partnerId String
|
||
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)
|
||
/// { ru, en, zh, hy, es }
|
||
title Json
|
||
/// минимальный уровень: пусто — любой член клуба, BAREV_PLUS — Barev+ и Elite, ELITE — только Elite
|
||
minLevel SubscriptionPlan?
|
||
limit OfferLimit @default(NONE)
|
||
limitCount Int @default(1)
|
||
active Boolean @default(true)
|
||
sortOrder Int @default(0)
|
||
|
||
usages CardUsage[]
|
||
}
|
||
|
||
enum PartnerRole {
|
||
OWNER
|
||
STAFF
|
||
}
|
||
|
||
/// Собственник или сотрудник партнёра — работает в рабочем боте: проверяет QR клиентов
|
||
model PartnerMember {
|
||
id String @id @default(cuid())
|
||
partnerId String
|
||
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id])
|
||
role PartnerRole
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([partnerId, userId])
|
||
}
|
||
|
||
/// Приглашение сотрудника: собственник показывает QR, сотрудник сканирует — t.me/<рабочий бот>?start=pi-<код>
|
||
model PartnerInvite {
|
||
id String @id @default(cuid())
|
||
code String @unique
|
||
partnerId String
|
||
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)
|
||
expiresAt DateTime
|
||
usedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
}
|
||
|
||
/// Проверка клубной карты у партнёра (offerId пусто) или списание лимитного предложения
|
||
model CardUsage {
|
||
id String @id @default(cuid())
|
||
clientId String
|
||
client Client @relation(fields: [clientId], references: [id])
|
||
partnerId String?
|
||
partner Partner? @relation(fields: [partnerId], references: [id])
|
||
offerId String?
|
||
offer PartnerOffer? @relation(fields: [offerId], references: [id], onDelete: SetNull)
|
||
/// кто из сотрудников партнёра проверил
|
||
memberUserId String?
|
||
/// уровень клиента на момент проверки
|
||
level String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([clientId, offerId, createdAt])
|
||
@@index([partnerId, createdAt])
|
||
}
|
||
|
||
// ───────────── Финансы ─────────────
|
||
|
||
model Payout {
|
||
id String @id @default(cuid())
|
||
providerId String?
|
||
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
|
||
status PayoutStatus @default(DRAFT)
|
||
/// когда выплачено и как (наличные, перевод…), кто отметил
|
||
paidAt DateTime?
|
||
note String?
|
||
createdById String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([providerId, createdAt])
|
||
@@index([agencyId, createdAt])
|
||
}
|
||
|
||
// ───────────── Админка ─────────────
|
||
|
||
model AdminUser {
|
||
id String @id @default(cuid())
|
||
/// вход по паролю — только старая админка; в новой вход через Telegram
|
||
email String? @unique
|
||
name String
|
||
passwordHash String?
|
||
role AdminRole @default(OPERATOR)
|
||
/// роли в новой админке
|
||
roles StaffRole[] @default([])
|
||
totpSecret String?
|
||
totpEnabled Boolean @default(false)
|
||
active Boolean @default(true)
|
||
/// Telegram оператора — уведомления приходят через рабочий бот
|
||
telegramId BigInt? @unique
|
||
/// одноразовый код ссылки t.me/<рабочий бот>?start=adm-<код>
|
||
tgLinkToken String? @unique
|
||
/// присылать уведомления в Telegram
|
||
tgNotify Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
|
||
sessions AdminSession[]
|
||
audit AuditLog[]
|
||
loginRequests AdminLoginRequest[]
|
||
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>,
|
||
/// бот подтверждает по telegramId, страница опрашивает статус и получает сессию
|
||
model AdminLoginRequest {
|
||
id String @id @default(cuid())
|
||
code String @unique
|
||
/// секрет браузера — по нему страница забирает сессию (code виден в ссылке)
|
||
pollKey String
|
||
adminUserId String?
|
||
adminUser AdminUser? @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
|
||
approvedAt DateTime?
|
||
consumedAt DateTime?
|
||
expiresAt DateTime
|
||
createdAt DateTime @default(now())
|
||
}
|
||
|
||
/// Сохранённая выборка клиентов (фильтры списка)
|
||
model SavedSegment {
|
||
id String @id @default(cuid())
|
||
name String
|
||
filters Json
|
||
adminUserId String
|
||
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
|
||
createdAt DateTime @default(now())
|
||
}
|
||
|
||
model AdminSession {
|
||
id String @id @default(cuid())
|
||
adminUserId String
|
||
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
|
||
/// sha256 токена сессии
|
||
tokenHash String @unique
|
||
/// сессия прошла 2FA
|
||
verified Boolean @default(false)
|
||
expiresAt DateTime
|
||
createdAt DateTime @default(now())
|
||
}
|
||
|
||
model AuditLog {
|
||
id String @id @default(cuid())
|
||
adminUserId String?
|
||
adminUser AdminUser? @relation(fields: [adminUserId], references: [id])
|
||
action String
|
||
entity String
|
||
entityId String?
|
||
data Json?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([entity, entityId])
|
||
}
|
||
|
||
/// Настройки (ключ → значение). Список ключей и дефолты — packages/core/src/settings.ts
|
||
model Setting {
|
||
key String @id
|
||
value Json
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
/// Короткий токен для deep-link с лендинга, когда payload не влезает в 64 символа (UTM)
|
||
model StartToken {
|
||
id String @id
|
||
locale Locale?
|
||
agency String?
|
||
utm Json?
|
||
createdAt DateTime @default(now())
|
||
}
|
||
|
||
/// Заявка на услугу с сайта (barevclub.com/clean). Попадает в админку сразу; когда человек открывает бота
|
||
/// по ссылке с token, заявка привязывается к клиенту и, если выбранное время свободно, становится заказом.
|
||
model WebRequest {
|
||
id String @id @default(cuid())
|
||
number Int @unique @default(autoincrement())
|
||
/// id StartToken — payload ссылки в бота
|
||
token String @unique
|
||
serviceId String
|
||
service Service @relation(fields: [serviceId], references: [id])
|
||
params Json
|
||
/// желаемое время, оператор подтверждает
|
||
preferredAt DateTime?
|
||
durationMinutes Int
|
||
districtId String?
|
||
district District? @relation(fields: [districtId], references: [id])
|
||
addressText String?
|
||
note String?
|
||
phone String
|
||
name String?
|
||
locale Locale
|
||
/// цена на момент заявки, без скидки Barev+
|
||
totalPrice Int
|
||
consentAt DateTime
|
||
consentVersion String
|
||
status WebRequestStatus @default(NEW)
|
||
clientId String?
|
||
client Client? @relation(fields: [clientId], references: [id])
|
||
orderId String? @unique
|
||
order Order? @relation(fields: [orderId], references: [id])
|
||
adminNote String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([status, createdAt])
|
||
}
|
||
|
||
enum LeadStatus {
|
||
NEW
|
||
IN_WORK
|
||
DONE
|
||
CANCELLED
|
||
}
|
||
|
||
enum LeadContactType {
|
||
PHONE
|
||
TELEGRAM
|
||
}
|
||
|
||
/// Заявка из квиза на главной («Переезжаю в Армению»). Создаётся сразу после шага с контактом,
|
||
/// дальше дополняется ответами; submittedAt — человек дошёл до конца.
|
||
model Lead {
|
||
id String @id @default(cuid())
|
||
number Int @unique @default(autoincrement())
|
||
/// секрет для дозаписи ответов из браузера (id видят в URL логов, ключ — нет)
|
||
key String
|
||
name String
|
||
contact String
|
||
contactType LeadContactType
|
||
locale Locale
|
||
agency String?
|
||
utm Json?
|
||
/// уже живёт в Армении — квиз отправил на /live
|
||
moved Boolean?
|
||
/// month | quarter | later | date
|
||
moveWhen String?
|
||
moveDate DateTime?
|
||
/// CONSULT | TURNKEY | CUSTOM
|
||
package String?
|
||
/// конструктор: { airport, stay, stayBudgetFrom, stayBudgetTo, flat, socialCard, bankCard, registration, residence }
|
||
options Json?
|
||
submittedAt DateTime?
|
||
consentAt DateTime
|
||
consentVersion String
|
||
status LeadStatus @default(NEW)
|
||
adminNote String?
|
||
/// клиент в приложении — заявка из приложения или найдена по @username из квиза
|
||
clientId String?
|
||
client Client? @relation(fields: [clientId], references: [id])
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
steps LeadStep[]
|
||
docs LeadDoc[]
|
||
|
||
@@index([status, createdAt])
|
||
@@index([clientId])
|
||
}
|
||
|
||
enum ClientSegment {
|
||
MOVING
|
||
LIVING
|
||
}
|
||
|
||
enum LeadStepStatus {
|
||
TODO
|
||
IN_PROGRESS
|
||
DONE
|
||
SKIPPED
|
||
}
|
||
|
||
/// Шаг плана переезда (аэропорт, жильё, соцкарта…) — статус двигает оператор, клиент видит прогресс
|
||
model LeadStep {
|
||
id String @id @default(cuid())
|
||
leadId String
|
||
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
|
||
/// код из шаблона (RelocationItem) или custom-… для шага, добавленного только этому клиенту
|
||
code String
|
||
/// своё название для этого клиента; пусто — название из шаблона
|
||
title String?
|
||
sortOrder Int
|
||
status LeadStepStatus @default(TODO)
|
||
/// комментарий оператора для клиента: «просмотры в среду в 15:00»
|
||
note String?
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([leadId, code])
|
||
}
|
||
|
||
enum RelocationItemKind {
|
||
STEP
|
||
DOC
|
||
}
|
||
|
||
/// Общий список шагов переезда и документов — редактируется в админке; у клиента копия, которую можно менять отдельно
|
||
model RelocationItem {
|
||
id String @id @default(cuid())
|
||
kind RelocationItemKind
|
||
code String
|
||
/// { ru, en, zh, hy, es }
|
||
name Json
|
||
/// подсказка под названием (для документов)
|
||
hint Json?
|
||
sortOrder Int @default(0)
|
||
active Boolean @default(true)
|
||
/// шаг входит в пакет «под ключ»
|
||
inTurnkey Boolean @default(true)
|
||
|
||
@@unique([kind, code])
|
||
}
|
||
|
||
/// Документ в чек-листе конкретного клиента
|
||
model LeadDoc {
|
||
id String @id @default(cuid())
|
||
leadId String
|
||
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
|
||
code String
|
||
title String?
|
||
checked Boolean @default(false)
|
||
sortOrder Int
|
||
|
||
@@unique([leadId, code])
|
||
}
|
||
|
||
enum ClientRequestKind {
|
||
SUBSCRIPTION
|
||
SERVICE
|
||
}
|
||
|
||
enum ClientRequestStatus {
|
||
NEW
|
||
DONE
|
||
CANCELLED
|
||
}
|
||
|
||
/// Заявка из приложения оператору: «хочу Elite», «перезвоните по ремонту»
|
||
model ClientRequest {
|
||
id String @id @default(cuid())
|
||
number Int @unique @default(autoincrement())
|
||
clientId String
|
||
client Client @relation(fields: [clientId], references: [id])
|
||
kind ClientRequestKind
|
||
plan SubscriptionPlan?
|
||
serviceCode String?
|
||
comment String?
|
||
status ClientRequestStatus @default(NEW)
|
||
adminNote String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([status, createdAt])
|
||
@@index([clientId])
|
||
}
|
||
|
||
enum ConciergeAuthor {
|
||
CLIENT
|
||
PROVIDER
|
||
OPERATOR
|
||
/// ИИ-консьерж (на будущее): отвечает сам, человек может перехватить диалог
|
||
BOT
|
||
/// служебная строка в ленте: «взял диалог», «передал», «закрыл»
|
||
SYSTEM
|
||
}
|
||
|
||
/// Сообщение в чате. Клиент пишет боту или в мини-аппе — всё попадает в один диалог (ChatThread).
|
||
/// Ответ команды приходит клиенту в чат с ботом (клиентский бот, для исполнителя — рабочий).
|
||
model ConciergeMessage {
|
||
id String @id @default(cuid())
|
||
/// диалог; у старых сообщений до этапа 2 проставлен миграцией
|
||
threadId String?
|
||
thread ChatThread? @relation(fields: [threadId], references: [id], onDelete: Cascade)
|
||
clientId String?
|
||
client Client? @relation(fields: [clientId], references: [id])
|
||
providerId String?
|
||
provider Provider? @relation(fields: [providerId], references: [id])
|
||
author ConciergeAuthor
|
||
/// кто из команды написал (author = OPERATOR / SYSTEM)
|
||
adminUserId String?
|
||
text String
|
||
/// внутренняя заметка команды — клиент её не видит
|
||
internal Boolean @default(false)
|
||
/// откуда пришло: BOT | MINIAPP | ADMIN
|
||
source String @default("BOT")
|
||
/// файл: ключ в приватном хранилище (загрузила команда / мини-апп)…
|
||
fileKey String?
|
||
/// …или file_id в Telegram (прислал клиент боту); бот — client | pro
|
||
tgFileId String?
|
||
tgBot String?
|
||
fileName String?
|
||
mime String?
|
||
size Int?
|
||
/// не удалось доставить в Telegram (клиент заблокировал бота и т.п.)
|
||
deliveryError String?
|
||
/// исполнитель писал по этому заказу (текущий заказ в момент сообщения); null — заказа тогда не было
|
||
orderId String?
|
||
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
|
||
/// оператор прочитал сообщение клиента
|
||
readAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([clientId, createdAt])
|
||
@@index([providerId, createdAt])
|
||
@@index([author, readAt])
|
||
@@index([threadId, createdAt])
|
||
}
|
||
|
||
enum ChatChannel {
|
||
/// поддержка — все клиенты и исполнители
|
||
SUPPORT
|
||
/// консьерж — только подписчики Barev+ / Elite
|
||
CONCIERGE
|
||
}
|
||
|
||
enum ChatStatus {
|
||
OPEN
|
||
CLOSED
|
||
}
|
||
|
||
/// Диалог: один на собеседника в каждом канале. Закрытый диалог открывается заново, когда клиент пишет снова.
|
||
model ChatThread {
|
||
id String @id @default(cuid())
|
||
channel ChatChannel
|
||
clientId String?
|
||
client Client? @relation(fields: [clientId], references: [id])
|
||
providerId String?
|
||
provider Provider? @relation(fields: [providerId], references: [id])
|
||
status ChatStatus @default(OPEN)
|
||
/// кто из команды ведёт диалог
|
||
assigneeId String?
|
||
assignee AdminUser? @relation(fields: [assigneeId], references: [id])
|
||
/// диалог ведёт ИИ-консьерж (задел на будущее); человек может перехватить
|
||
botActive Boolean @default(false)
|
||
lastMessageAt DateTime @default(now())
|
||
/// с какого момента клиент ждёт ответа; null — ответили
|
||
waitingSince DateTime?
|
||
closedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
messages ConciergeMessage[]
|
||
|
||
@@unique([clientId, channel])
|
||
@@unique([providerId, channel])
|
||
@@index([channel, status, lastMessageAt])
|
||
@@index([assigneeId, status])
|
||
}
|
||
|
||
/// Шаблон быстрого ответа в чате. {name} подставляется именем клиента.
|
||
model ChatTemplate {
|
||
id String @id @default(cuid())
|
||
/// для какого канала; null — для обоих
|
||
channel ChatChannel?
|
||
title String
|
||
text String
|
||
createdById String?
|
||
createdBy AdminUser? @relation(fields: [createdById], references: [id])
|
||
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) ─────────────
|
||
|
||
/// Текст, изменённый в админке поверх переводов из packages/i18n (название и описание услуги, пункт чек-листа…).
|
||
/// Пустой — не храним: действует стандартный перевод
|
||
model TextOverride {
|
||
key String
|
||
locale Locale
|
||
value String
|
||
updatedById String?
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@id([key, locale])
|
||
}
|
||
|
||
enum BroadcastStatus {
|
||
SENDING
|
||
DONE
|
||
CANCELLED
|
||
}
|
||
|
||
enum BroadcastRecipientStatus {
|
||
PENDING
|
||
SENT
|
||
/// заблокировал бота или ни разу его не открывал
|
||
BLOCKED
|
||
FAILED
|
||
}
|
||
|
||
/// Рассылка клиентам в бот по сегменту (фильтры списка клиентов). Текст — на языке клиента, фолбэк — русский
|
||
model Broadcast {
|
||
id String @id @default(cuid())
|
||
/// { ru, en, zh, hy, es }
|
||
texts Json
|
||
/// кнопка «Открыть приложение» под сообщением
|
||
appButton Boolean @default(true)
|
||
/// фильтры списка клиентов, по которым собрали получателей
|
||
filters Json
|
||
status BroadcastStatus @default(SENDING)
|
||
total Int @default(0)
|
||
sent Int @default(0)
|
||
blocked Int @default(0)
|
||
failed Int @default(0)
|
||
createdById String?
|
||
createdAt DateTime @default(now())
|
||
finishedAt DateTime?
|
||
|
||
recipients BroadcastRecipient[]
|
||
}
|
||
|
||
model BroadcastRecipient {
|
||
id String @id @default(cuid())
|
||
broadcastId String
|
||
broadcast Broadcast @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
|
||
clientId String
|
||
status BroadcastRecipientStatus @default(PENDING)
|
||
error String?
|
||
sentAt DateTime?
|
||
|
||
@@unique([broadcastId, clientId])
|
||
@@index([broadcastId, status])
|
||
}
|
||
|
||
/// Вход в кабинет менеджера партнёра с компьютера: бот выдаёт одноразовую ссылку (code), браузер меняет её на токен
|
||
model ProWebSession {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
/// sha256 одноразового кода из ссылки; после входа обнуляется
|
||
codeHash String? @unique
|
||
codeExpiresAt DateTime?
|
||
/// sha256 токена браузера
|
||
tokenHash String? @unique
|
||
expiresAt DateTime
|
||
lastUsedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([userId])
|
||
}
|
||
|
||
// ───────────── Барики: кэшбек клуба (1 барик = 1 ֏) ─────────────
|
||
|
||
enum PointsKind {
|
||
ORDER
|
||
BONUS
|
||
}
|
||
|
||
enum PointsStatus {
|
||
/// создано, тратить нельзя до availableAt
|
||
PENDING
|
||
ACTIVE
|
||
/// израсходовано полностью
|
||
SPENT
|
||
/// сгорело
|
||
EXPIRED
|
||
/// отменено
|
||
REVERSED
|
||
}
|
||
|
||
enum PointsChannel {
|
||
WEB
|
||
BOT
|
||
CSV
|
||
AUTO
|
||
CLIENT
|
||
}
|
||
|
||
enum RedemptionStatus {
|
||
REQUESTED
|
||
CONFIRMED
|
||
REJECTED
|
||
REVERSED
|
||
}
|
||
|
||
enum SuggestionStatus {
|
||
OPEN
|
||
ACCEPTED
|
||
REJECTED
|
||
}
|
||
|
||
/// Категория кэшбека со ставками по уровням, %
|
||
model PointsCategory {
|
||
id String @id @default(cuid())
|
||
code String @unique
|
||
/// { ru, en, zh, hy, es }
|
||
name Json
|
||
rateBarev Int
|
||
ratePlus Int
|
||
rateElite Int
|
||
active Boolean @default(true)
|
||
sortOrder Int @default(0)
|
||
|
||
services Service[]
|
||
accruals PointsAccrual[]
|
||
}
|
||
|
||
/// Тип бонуса: новоселье, отзыв, компенсация…
|
||
model PointsBonusType {
|
||
id String @id @default(cuid())
|
||
code String @unique
|
||
name Json
|
||
defaultAmount Int
|
||
/// срок жизни бонуса в днях; пусто — общее правило сгорания
|
||
ttlDays Int?
|
||
active Boolean @default(true)
|
||
sortOrder Int @default(0)
|
||
|
||
accruals PointsAccrual[]
|
||
}
|
||
|
||
/// Начисление бариков (журнал: не удаляется и не правится, кроме остатка и статуса)
|
||
model PointsAccrual {
|
||
id String @id @default(cuid())
|
||
clientId String
|
||
client Client @relation(fields: [clientId], references: [id])
|
||
kind PointsKind
|
||
categoryId String?
|
||
category PointsCategory? @relation(fields: [categoryId], references: [id])
|
||
bonusTypeId String?
|
||
bonusType PointsBonusType? @relation(fields: [bonusTypeId], references: [id])
|
||
/// заказ в системе (автоначисление)
|
||
orderId String?
|
||
order Order? @relation(fields: [orderId], references: [id])
|
||
/// номер заказа для сверки; среди неотменённых начислений уникален (индекс в миграции)
|
||
orderRef String?
|
||
orderAmount Int?
|
||
excludedAmount Int @default(0)
|
||
paidWithPoints Int @default(0)
|
||
/// BAREV | BAREV_PLUS | ELITE — на дату услуги
|
||
tier String?
|
||
ratePct Int?
|
||
/// надбавка за серию уборок, п.п.
|
||
streakBonusPct Int @default(0)
|
||
suggestedAmount Int?
|
||
amount Int
|
||
remaining Int
|
||
/// сколько сгорело
|
||
expiredAmount Int @default(0)
|
||
status PointsStatus
|
||
serviceDate DateTime
|
||
availableAt DateTime
|
||
/// свой срок жизни (бонусы); пусто — общее правило сгорания
|
||
expiresAt DateTime?
|
||
reason String?
|
||
commentForClient String?
|
||
createdById String?
|
||
channel PointsChannel
|
||
idempotencyKey String? @unique
|
||
createdAt DateTime @default(now())
|
||
|
||
items PointsRedemptionItem[]
|
||
|
||
@@index([clientId, status])
|
||
@@index([status, availableAt])
|
||
@@index([orderId])
|
||
@@index([createdAt])
|
||
}
|
||
|
||
/// Списание (оплата части заказа бариками)
|
||
model PointsRedemption {
|
||
id String @id @default(cuid())
|
||
clientId String
|
||
client Client @relation(fields: [clientId], references: [id])
|
||
orderId String?
|
||
order Order? @relation(fields: [orderId], references: [id])
|
||
orderRef String?
|
||
orderAmount Int?
|
||
amount Int
|
||
status RedemptionStatus
|
||
channel PointsChannel
|
||
/// комментарий клиента к запросу
|
||
note String?
|
||
requestedAt DateTime @default(now())
|
||
expiresAt DateTime?
|
||
confirmedById String?
|
||
confirmedAt DateTime?
|
||
reason String?
|
||
idempotencyKey String? @unique
|
||
createdAt DateTime @default(now())
|
||
|
||
items PointsRedemptionItem[]
|
||
|
||
@@index([clientId, status])
|
||
@@index([status, requestedAt])
|
||
}
|
||
|
||
/// Из каких начислений списано (FIFO)
|
||
model PointsRedemptionItem {
|
||
id String @id @default(cuid())
|
||
redemptionId String
|
||
redemption PointsRedemption @relation(fields: [redemptionId], references: [id])
|
||
accrualId String
|
||
accrual PointsAccrual @relation(fields: [accrualId], references: [id])
|
||
amount Int
|
||
|
||
@@index([accrualId])
|
||
}
|
||
|
||
/// Отмена операции — обратная запись со ссылкой на исходную
|
||
model PointsReversal {
|
||
id String @id @default(cuid())
|
||
/// ACCRUAL | REDEMPTION
|
||
targetType String
|
||
targetId String
|
||
clientId String
|
||
amount Int
|
||
/// сколько ушло в долг (отмена уже потраченного начисления)
|
||
debt Int @default(0)
|
||
createdById String?
|
||
channel PointsChannel
|
||
reason String
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([targetType, targetId])
|
||
@@index([clientId])
|
||
@@index([createdAt])
|
||
}
|
||
|
||
/// Предложено системой: начислить бонус или подтвердить операцию оператора сверх лимита
|
||
model PointsSuggestion {
|
||
id String @id @default(cuid())
|
||
clientId String
|
||
client Client @relation(fields: [clientId], references: [id])
|
||
/// route | photo_review | over_limit | …
|
||
mechanic String
|
||
/// чтобы одна и та же причина не создавала карточку дважды
|
||
dedupeKey String @unique
|
||
payload Json
|
||
suggestedAmount Int
|
||
status SuggestionStatus @default(OPEN)
|
||
resolvedById String?
|
||
resolvedAt DateTime?
|
||
reason String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([status, createdAt])
|
||
}
|