// 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 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 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 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-, /// бот подтверждает по 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]) }