Files
BarevClub/packages/db/prisma/schema.prisma
T
2026-10-25 22:57:03 +04:00

1743 lines
59 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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])
}