Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:23:32 +04:00
1 parent 45d7512d2b
commit d452824c62
314 files changed
+44394 -121

No files matched your search

+398 -14
View File
@@ -107,8 +107,17 @@ enum AdminRole {
OPERATOR
}
/// Роли новой админки (apps/backoffice). У одного человека может быть несколько.
enum StaffRole {
SUPERADMIN
SUPPORT
CONCIERGE
SALES
}
enum SubscriptionPlan {
BAREV_PLUS
ELITE
}
enum SubscriptionSource {
@@ -147,6 +156,15 @@ enum TaskStatus {
DONE
}
/// Заявка с сайта: NEW — человек ещё не открыл бота; LINKED — открыл, но заказ не создан (нужен звонок оператора);
/// ORDERED — стала заказом; CANCELLED — отменена оператором
enum WebRequestStatus {
NEW
LINKED
ORDERED
CANCELLED
}
enum PayoutStatus {
DRAFT
APPROVED
@@ -170,6 +188,7 @@ model User {
client Client?
provider Provider?
dispatcher CompanyDispatcher[]
partnerMembers PartnerMember[]
messages Message[]
}
@@ -189,6 +208,12 @@ model Client {
giftUsedAt DateTime?
/// короткий код клубной карты (Этап 3)
cardCode String @unique
/// сценарий приложения: переезжает или уже живёт в Армении; null — ещё не выбрал
segment ClientSegment?
/// метки команды: VIP, проблемный…
tags String[] @default([])
/// заметка команды о клиенте (клиент не видит)
adminNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -197,6 +222,10 @@ model Client {
ratings Rating[]
cardUsages CardUsage[]
promoUses PromoCodeUse[]
webRequests WebRequest[]
leads Lead[]
requests ClientRequest[]
concierge ConciergeMessage[]
@@index([agencyId])
}
@@ -254,6 +283,9 @@ model Provider {
onShift Boolean @default(false)
active Boolean @default(true)
inviteCode String @unique
/// пресет ставок (джун / мидл / прайват); если задан — заменяет rateType/rateValue
payoutPresetId String?
payoutPreset PayoutPreset? @relation(fields: [payoutPresetId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -265,14 +297,30 @@ model Provider {
payouts Payout[]
@@index([role, active])
concierge ConciergeMessage[]
}
/// Пресет ставок исполнителя: для каждой строки цены — фикс или % (см. 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[]
providers ProviderDistrict[]
orders Order[]
webRequests WebRequest[]
}
model ProviderDistrict {
@@ -304,6 +352,8 @@ model Category {
sortOrder Int @default(0)
/// скидка Barev+ на направление, %
barevPlusDiscountPct Int @default(0)
/// скидка Barev Elite на направление, %
eliteDiscountPct Int @default(0)
active Boolean @default(true)
services Service[]
}
@@ -330,8 +380,9 @@ model Service {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
checklist ChecklistItem[]
orders Order[]
checklist ChecklistItem[]
orders Order[]
webRequests WebRequest[]
}
/// Шаблон пункта чек-листа услуги
@@ -379,6 +430,8 @@ model Order {
/// язык клиента на момент заказа
locale Locale
basePrice Int
/// разбивка цены на момент заказа (основа, комнаты, санузлы, доп. услуги) — для расчёта выплат
priceBreakdown Json?
discountPct Int @default(0)
discountAmount Int @default(0)
totalPrice Int
@@ -413,6 +466,7 @@ model Order {
payments Payment[]
commission AgencyCommission?
tasks OperatorTask[]
webRequest WebRequest?
@@index([status])
@@index([scheduledAt])
@@ -644,9 +698,22 @@ model Subscription {
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) ─────────────
@@ -661,18 +728,90 @@ model Partner {
terms Json?
address String?
active Boolean @default(true)
/// одноразовая ссылка для собственника: t.me/<рабочий бот>?start=po-<код> (выдаёт админка)
ownerInviteCode String? @unique
createdAt DateTime @default(now())
usages CardUsage[]
offers PartnerOffer[]
members PartnerMember[]
invites PartnerInvite[]
}
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])
createdAt DateTime @default(now())
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])
}
// ───────────── Финансы ─────────────
@@ -694,17 +833,53 @@ model Payout {
model AdminUser {
id String @id @default(cuid())
email String @unique
/// вход по паролю — только старая админка; в новой вход через Telegram
email String? @unique
name String
passwordHash 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[]
sessions AdminSession[]
audit AuditLog[]
loginRequests AdminLoginRequest[]
segments SavedSegment[]
}
/// Вход в новую админку через 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 {
@@ -747,3 +922,212 @@ model StartToken {
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
}
/// Переписка из админки: с клиентом (клиентский бот) или с исполнителем (рабочий бот); ответ приходит в тот же чат с ботом
model ConciergeMessage {
id String @id @default(cuid())
clientId String?
client Client? @relation(fields: [clientId], references: [id])
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
author ConciergeAuthor
/// кто из операторов ответил
adminUserId String?
text String
/// оператор прочитал сообщение клиента
readAt DateTime?
createdAt DateTime @default(now())
@@index([clientId, createdAt])
@@index([providerId, createdAt])
@@index([author, readAt])
}