Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra

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-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+8 -3
View File
@@ -3,16 +3,21 @@
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"exports": {
".": "./src/index.ts"
},
"scripts": {
"generate": "prisma generate",
"migrate": "prisma migrate dev",
"deploy": "prisma migrate deploy",
"reset": "prisma migrate reset --force",
"seed": "tsx src/seed/index.ts",
"studio": "prisma studio",
"typecheck": "tsc --noEmit",
"postinstall": "prisma generate"
"postinstall": "prisma generate",
"reset-2fa": "tsx src/scripts/reset-2fa.ts",
"migrate:deploy": "prisma migrate deploy",
"create-admin": "tsx src/scripts/create-admin.ts",
"sync-catalog": "tsx src/scripts/sync-catalog.ts"
},
"dependencies": {
"@prisma/adapter-pg": "7.10.1",
+3 -2
View File
@@ -1,11 +1,12 @@
import path from 'node:path';
import { config } from 'dotenv';
import { defineConfig, env } from 'prisma/config';
import { defineConfig } from 'prisma/config';
config({ path: path.resolve(import.meta.dirname, '../../.env'), quiet: true });
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations', seed: 'tsx src/seed/index.ts' },
datasource: { url: env('DATABASE_URL') },
// generate (в т.ч. при сборке Docker-образа) не требует БД; migrate без DATABASE_URL упадёт с понятной ошибкой
datasource: { url: process.env.DATABASE_URL ?? '' },
});
@@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "priceBreakdown" JSONB;
-- AlterTable
ALTER TABLE "Provider" ADD COLUMN "payoutPresetId" TEXT;
-- CreateTable
CREATE TABLE "PayoutPreset" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "ProviderRole" NOT NULL,
"rules" JSONB NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PayoutPreset_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Provider" ADD CONSTRAINT "Provider_payoutPresetId_fkey" FOREIGN KEY ("payoutPresetId") REFERENCES "PayoutPreset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,54 @@
-- CreateEnum
CREATE TYPE "WebRequestStatus" AS ENUM ('NEW', 'LINKED', 'ORDERED', 'CANCELLED');
-- CreateTable
CREATE TABLE "WebRequest" (
"id" TEXT NOT NULL,
"number" SERIAL NOT NULL,
"token" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"params" JSONB NOT NULL,
"preferredAt" TIMESTAMP(3) NOT NULL,
"durationMinutes" INTEGER NOT NULL,
"districtId" TEXT NOT NULL,
"addressText" TEXT NOT NULL,
"note" TEXT,
"phone" TEXT NOT NULL,
"name" TEXT,
"locale" "Locale" NOT NULL,
"totalPrice" INTEGER NOT NULL,
"consentAt" TIMESTAMP(3) NOT NULL,
"consentVersion" TEXT NOT NULL,
"status" "WebRequestStatus" NOT NULL DEFAULT 'NEW',
"clientId" TEXT,
"orderId" TEXT,
"adminNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WebRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WebRequest_number_key" ON "WebRequest"("number");
-- CreateIndex
CREATE UNIQUE INDEX "WebRequest_token_key" ON "WebRequest"("token");
-- CreateIndex
CREATE UNIQUE INDEX "WebRequest_orderId_key" ON "WebRequest"("orderId");
-- CreateIndex
CREATE INDEX "WebRequest_status_createdAt_idx" ON "WebRequest"("status", "createdAt");
-- AddForeignKey
ALTER TABLE "WebRequest" ADD CONSTRAINT "WebRequest_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebRequest" ADD CONSTRAINT "WebRequest_districtId_fkey" FOREIGN KEY ("districtId") REFERENCES "District"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebRequest" ADD CONSTRAINT "WebRequest_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebRequest" ADD CONSTRAINT "WebRequest_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,38 @@
-- CreateEnum
CREATE TYPE "LeadStatus" AS ENUM ('NEW', 'IN_WORK', 'DONE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "LeadContactType" AS ENUM ('PHONE', 'TELEGRAM');
-- CreateTable
CREATE TABLE "Lead" (
"id" TEXT NOT NULL,
"number" SERIAL NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"contact" TEXT NOT NULL,
"contactType" "LeadContactType" NOT NULL,
"locale" "Locale" NOT NULL,
"agency" TEXT,
"utm" JSONB,
"moved" BOOLEAN,
"moveWhen" TEXT,
"moveDate" TIMESTAMP(3),
"package" TEXT,
"options" JSONB,
"submittedAt" TIMESTAMP(3),
"consentAt" TIMESTAMP(3) NOT NULL,
"consentVersion" TEXT NOT NULL,
"status" "LeadStatus" NOT NULL DEFAULT 'NEW',
"adminNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Lead_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Lead_number_key" ON "Lead"("number");
-- CreateIndex
CREATE INDEX "Lead_status_createdAt_idx" ON "Lead"("status", "createdAt");
@@ -0,0 +1,106 @@
-- CreateEnum
CREATE TYPE "ClientSegment" AS ENUM ('MOVING', 'LIVING');
-- CreateEnum
CREATE TYPE "LeadStepStatus" AS ENUM ('TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED');
-- CreateEnum
CREATE TYPE "ClientRequestKind" AS ENUM ('SUBSCRIPTION', 'SERVICE');
-- CreateEnum
CREATE TYPE "ClientRequestStatus" AS ENUM ('NEW', 'DONE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "ConciergeAuthor" AS ENUM ('CLIENT', 'OPERATOR');
-- AlterEnum
ALTER TYPE "SubscriptionPlan" ADD VALUE 'ELITE';
-- AlterTable
ALTER TABLE "Category" ADD COLUMN "eliteDiscountPct" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Client" ADD COLUMN "segment" "ClientSegment";
-- AlterTable
ALTER TABLE "Lead" ADD COLUMN "checklist" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "clientId" TEXT;
-- CreateTable
CREATE TABLE "LeadStep" (
"id" TEXT NOT NULL,
"leadId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL,
"status" "LeadStepStatus" NOT NULL DEFAULT 'TODO',
"note" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LeadStep_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ClientRequest" (
"id" TEXT NOT NULL,
"number" SERIAL NOT NULL,
"clientId" TEXT NOT NULL,
"kind" "ClientRequestKind" NOT NULL,
"plan" "SubscriptionPlan",
"serviceCode" TEXT,
"comment" TEXT,
"status" "ClientRequestStatus" NOT NULL DEFAULT 'NEW',
"adminNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ClientRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ConciergeMessage" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"author" "ConciergeAuthor" NOT NULL,
"adminUserId" TEXT,
"text" TEXT NOT NULL,
"readAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ConciergeMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "LeadStep_leadId_code_key" ON "LeadStep"("leadId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "ClientRequest_number_key" ON "ClientRequest"("number");
-- CreateIndex
CREATE INDEX "ClientRequest_status_createdAt_idx" ON "ClientRequest"("status", "createdAt");
-- CreateIndex
CREATE INDEX "ClientRequest_clientId_idx" ON "ClientRequest"("clientId");
-- CreateIndex
CREATE INDEX "ConciergeMessage_clientId_createdAt_idx" ON "ConciergeMessage"("clientId", "createdAt");
-- CreateIndex
CREATE INDEX "ConciergeMessage_author_readAt_idx" ON "ConciergeMessage"("author", "readAt");
-- CreateIndex
CREATE INDEX "Lead_clientId_idx" ON "Lead"("clientId");
-- AddForeignKey
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LeadStep" ADD CONSTRAINT "LeadStep_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ClientRequest" ADD CONSTRAINT "ClientRequest_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConciergeMessage" ADD CONSTRAINT "ConciergeMessage_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Elite: скидки по направлениям как на главной («до −35%»)
UPDATE "Category" SET "eliteDiscountPct" = 35 WHERE "code" IN ('HOME', 'DOCS', 'LEARNING');
@@ -0,0 +1,94 @@
-- CreateEnum
CREATE TYPE "RelocationItemKind" AS ENUM ('STEP', 'DOC');
-- AlterEnum
ALTER TYPE "ConciergeAuthor" ADD VALUE 'PROVIDER';
-- DropForeignKey
ALTER TABLE "ConciergeMessage" DROP CONSTRAINT "ConciergeMessage_clientId_fkey";
-- AlterTable
ALTER TABLE "AdminUser" ADD COLUMN "telegramId" BIGINT,
ADD COLUMN "tgLinkToken" TEXT,
ADD COLUMN "tgNotify" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE "ConciergeMessage" ADD COLUMN "providerId" TEXT,
ALTER COLUMN "clientId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Lead" DROP COLUMN "checklist";
-- AlterTable
ALTER TABLE "LeadStep" ADD COLUMN "title" TEXT;
-- CreateTable
CREATE TABLE "RelocationItem" (
"id" TEXT NOT NULL,
"kind" "RelocationItemKind" NOT NULL,
"code" TEXT NOT NULL,
"name" JSONB NOT NULL,
"hint" JSONB,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"inTurnkey" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "RelocationItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LeadDoc" (
"id" TEXT NOT NULL,
"leadId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"title" TEXT,
"checked" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL,
CONSTRAINT "LeadDoc_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "RelocationItem_kind_code_key" ON "RelocationItem"("kind", "code");
-- CreateIndex
CREATE UNIQUE INDEX "LeadDoc_leadId_code_key" ON "LeadDoc"("leadId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_telegramId_key" ON "AdminUser"("telegramId");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_tgLinkToken_key" ON "AdminUser"("tgLinkToken");
-- CreateIndex
CREATE INDEX "ConciergeMessage_providerId_createdAt_idx" ON "ConciergeMessage"("providerId", "createdAt");
-- AddForeignKey
ALTER TABLE "LeadDoc" ADD CONSTRAINT "LeadDoc_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConciergeMessage" ADD CONSTRAINT "ConciergeMessage_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConciergeMessage" ADD CONSTRAINT "ConciergeMessage_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- стартовые шаблоны: шаги переезда и чек-лист документов (дальше правятся в админке)
INSERT INTO "RelocationItem" ("id", "kind", "code", "name", "hint", "sortOrder", "active", "inTurnkey") VALUES
(gen_random_uuid()::text, 'STEP', 'consult', '{"ru": "Консультация по переезду", "en": "Relocation consultation", "zh": "搬家咨询", "hy": "Տեղափոխության խորհրդատվություն", "es": "Consulta de mudanza"}'::jsonb, NULL, 0, true, false),
(gen_random_uuid()::text, 'STEP', 'airport', '{"ru": "Встреча в аэропорту", "en": "Airport pickup", "zh": "机场接机", "hy": "Դիմավորում օդանավակայանում", "es": "Recogida en el aeropuerto"}'::jsonb, NULL, 1, true, true),
(gen_random_uuid()::text, 'STEP', 'stay', '{"ru": "Жильё на первое время", "en": "A place for the first days", "zh": "初期住宿", "hy": "Բնակություն առաջին օրերի համար", "es": "Alojamiento para empezar"}'::jsonb, NULL, 2, true, true),
(gen_random_uuid()::text, 'STEP', 'flat', '{"ru": "Поиск квартиры", "en": "Flat search", "zh": "找房", "hy": "Բնակարանի որոնում", "es": "Búsqueda de piso"}'::jsonb, NULL, 3, true, true),
(gen_random_uuid()::text, 'STEP', 'registration', '{"ru": "Регистрация", "en": "Registration", "zh": "登记", "hy": "Գրանցում", "es": "Empadronamiento"}'::jsonb, NULL, 4, true, true),
(gen_random_uuid()::text, 'STEP', 'socialCard', '{"ru": "Соцкарта", "en": "Social card", "zh": "社保卡", "hy": "Սոցքարտ", "es": "Tarjeta social"}'::jsonb, NULL, 5, true, true),
(gen_random_uuid()::text, 'STEP', 'bankCard', '{"ru": "Банковская карта", "en": "Bank card", "zh": "银行卡", "hy": "Բանկային քարտ", "es": "Tarjeta bancaria"}'::jsonb, NULL, 6, true, true),
(gen_random_uuid()::text, 'STEP', 'residence', '{"ru": "ВНЖ", "en": "Residence permit", "zh": "居留许可", "hy": "Կացության քարտ", "es": "Permiso de residencia"}'::jsonb, NULL, 7, true, true),
(gen_random_uuid()::text, 'DOC', 'passport', '{"ru": "Загранпаспорт", "en": "International passport", "zh": "护照", "hy": "Արտասահմանյան անձնագիր", "es": "Pasaporte"}'::jsonb, '{"ru": "Действует ещё минимум 6 месяцев после переезда", "en": "Valid for at least 6 months after the move", "zh": "搬家后至少还有 6 个月有效期", "hy": "Վավեր տեղափոխվելուց հետո առնվազն 6 ամիս", "es": "Vigente al menos 6 meses después de la mudanza"}'::jsonb, 0, true, false),
(gen_random_uuid()::text, 'DOC', 'passportCopies', '{"ru": "Копии паспорта", "en": "Passport copies", "zh": "护照复印件", "hy": "Անձնագրի պատճեններ", "es": "Copias del pasaporte"}'::jsonb, '{"ru": "2–3 копии страницы с фото", "en": "2–3 copies of the photo page", "zh": "照片页复印 2–3 份", "hy": "Լուսանկարով էջի 2–3 պատճեն", "es": "2–3 copias de la página con foto"}'::jsonb, 1, true, false),
(gen_random_uuid()::text, 'DOC', 'photos', '{"ru": "Фото 3×4", "en": "3×4 photos", "zh": "3×4 照片", "hy": "3×4 լուսանկարներ", "es": "Fotos 3×4"}'::jsonb, '{"ru": "4–6 штук для ВНЖ и соцкарты", "en": "4–6 for the residence permit and social card", "zh": "4–6 张,用于居留许可和社保卡", "hy": "4–6 հատ՝ կացության քարտի և սոցքարտի համար", "es": "4–6 para la residencia y la tarjeta social"}'::jsonb, 2, true, false),
(gen_random_uuid()::text, 'DOC', 'birthCertificate', '{"ru": "Свидетельство о рождении", "en": "Birth certificate", "zh": "出生证明", "hy": "Ծննդյան վկայական", "es": "Partida de nacimiento"}'::jsonb, '{"ru": "С апостилем — нужно для ВНЖ", "en": "Apostilled — needed for the residence permit", "zh": "需海牙认证,办理居留许可时使用", "hy": "Ապոստիլով՝ պետք է կացության քարտի համար", "es": "Apostillada: se necesita para la residencia"}'::jsonb, 3, true, false),
(gen_random_uuid()::text, 'DOC', 'marriageCertificate', '{"ru": "Свидетельство о браке", "en": "Marriage certificate", "zh": "结婚证", "hy": "Ամուսնության վկայական", "es": "Certificado de matrimonio"}'::jsonb, '{"ru": "С апостилем, если переезжаете семьёй", "en": "Apostilled, if moving as a family", "zh": "需海牙认证,全家搬迁时需要", "hy": "Ապոստիլով, եթե տեղափոխվում եք ընտանիքով", "es": "Apostillado, si os mudáis en familia"}'::jsonb, 4, true, false),
(gen_random_uuid()::text, 'DOC', 'diploma', '{"ru": "Диплом", "en": "Diploma", "zh": "学历证书", "hy": "Դիպլոմ", "es": "Título universitario"}'::jsonb, '{"ru": "С апостилем — пригодится для работы и ВНЖ", "en": "Apostilled — useful for work and the residence permit", "zh": "需海牙认证,工作和居留许可时有用", "hy": "Ապոստիլով՝ պետք կգա աշխատանքի և կացության քարտի համար", "es": "Apostillado: útil para trabajar y para la residencia"}'::jsonb, 5, true, false),
(gen_random_uuid()::text, 'DOC', 'criminalRecord', '{"ru": "Справка о несудимости", "en": "Criminal record certificate", "zh": "无犯罪记录证明", "hy": "Դատվածության բացակայության տեղեկանք", "es": "Certificado de antecedentes penales"}'::jsonb, '{"ru": "С апостилем, действует 3 месяца", "en": "Apostilled, valid for 3 months", "zh": "需海牙认证,有效期 3 个月", "hy": "Ապոստիլով, վավեր է 3 ամիս", "es": "Apostillado, válido 3 meses"}'::jsonb, 6, true, false),
(gen_random_uuid()::text, 'DOC', 'insurance', '{"ru": "Медицинская страховка", "en": "Health insurance", "zh": "医疗保险", "hy": "Բժշկական ապահովագրություն", "es": "Seguro médico"}'::jsonb, '{"ru": "На первые месяцы, пока нет ВНЖ", "en": "For the first months before the residence permit", "zh": "覆盖办下居留许可前的头几个月", "hy": "Առաջին ամիսների համար, մինչև կացության քարտը", "es": "Para los primeros meses, hasta tener la residencia"}'::jsonb, 7, true, false)
ON CONFLICT ("kind", "code") DO NOTHING;
@@ -0,0 +1,81 @@
-- CreateEnum
CREATE TYPE "OfferLimit" AS ENUM ('NONE', 'ONCE', 'WEEK', 'MONTH');
-- CreateEnum
CREATE TYPE "PartnerRole" AS ENUM ('OWNER', 'STAFF');
-- AlterTable
ALTER TABLE "CardUsage" ADD COLUMN "level" TEXT,
ADD COLUMN "memberUserId" TEXT,
ADD COLUMN "offerId" TEXT;
-- AlterTable
ALTER TABLE "Partner" ADD COLUMN "ownerInviteCode" TEXT;
-- CreateTable
CREATE TABLE "PartnerOffer" (
"id" TEXT NOT NULL,
"partnerId" TEXT NOT NULL,
"title" JSONB NOT NULL,
"minLevel" "SubscriptionPlan",
"limit" "OfferLimit" NOT NULL DEFAULT 'NONE',
"limitCount" INTEGER NOT NULL DEFAULT 1,
"active" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "PartnerOffer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PartnerMember" (
"id" TEXT NOT NULL,
"partnerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "PartnerRole" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PartnerMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PartnerInvite" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"partnerId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PartnerInvite_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PartnerMember_partnerId_userId_key" ON "PartnerMember"("partnerId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "PartnerInvite_code_key" ON "PartnerInvite"("code");
-- CreateIndex
CREATE INDEX "CardUsage_clientId_offerId_createdAt_idx" ON "CardUsage"("clientId", "offerId", "createdAt");
-- CreateIndex
CREATE INDEX "CardUsage_partnerId_createdAt_idx" ON "CardUsage"("partnerId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "Partner_ownerInviteCode_key" ON "Partner"("ownerInviteCode");
-- AddForeignKey
ALTER TABLE "PartnerOffer" ADD CONSTRAINT "PartnerOffer_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartnerMember" ADD CONSTRAINT "PartnerMember_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartnerMember" ADD CONSTRAINT "PartnerMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartnerInvite" ADD CONSTRAINT "PartnerInvite_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CardUsage" ADD CONSTRAINT "CardUsage_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "PartnerOffer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,64 @@
-- CreateEnum
CREATE TYPE "StaffRole" AS ENUM ('SUPERADMIN', 'SUPPORT', 'CONCIERGE', 'SALES');
-- AlterTable
ALTER TABLE "AdminUser" ADD COLUMN "roles" "StaffRole"[] DEFAULT ARRAY[]::"StaffRole"[],
ALTER COLUMN "email" DROP NOT NULL,
ALTER COLUMN "passwordHash" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Client" ADD COLUMN "adminNote" TEXT,
ADD COLUMN "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "agencyId" TEXT,
ADD COLUMN "amount" INTEGER,
ADD COLUMN "cancelledAt" TIMESTAMP(3),
ADD COLUMN "grantedByAdminId" TEXT,
ADD COLUMN "note" TEXT,
ADD COLUMN "payMethod" TEXT,
ADD COLUMN "refundAmount" INTEGER,
ADD COLUMN "refundedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "AdminLoginRequest" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"pollKey" TEXT NOT NULL,
"adminUserId" TEXT,
"approvedAt" TIMESTAMP(3),
"consumedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminLoginRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SavedSegment" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"filters" JSONB NOT NULL,
"adminUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SavedSegment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AdminLoginRequest_code_key" ON "AdminLoginRequest"("code");
-- CreateIndex
CREATE INDEX "Subscription_createdAt_idx" ON "Subscription"("createdAt");
-- AddForeignKey
ALTER TABLE "AdminLoginRequest" ADD CONSTRAINT "AdminLoginRequest_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SavedSegment" ADD CONSTRAINT "SavedSegment_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- существующие админы получают роли новой админки
UPDATE "AdminUser" SET "roles" = ARRAY['SUPERADMIN']::"StaffRole"[] WHERE "role" = 'OWNER';
UPDATE "AdminUser" SET "roles" = ARRAY['SUPPORT','CONCIERGE']::"StaffRole"[] WHERE "role" = 'OPERATOR';
-- подарки агентств: запоминаем агентство
UPDATE "Subscription" s SET "agencyId" = c."agencyId" FROM "Client" c WHERE s."clientId" = c.id AND s.source = 'AGENCY_GIFT';
+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])
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Создать или обновить администратора (пароль — из переменной ADMIN_PASSWORD, чтобы не светить его в истории команд).
*
* ADMIN_PASSWORD=… pnpm --filter @barev/db create-admin <логин> [OWNER|OPERATOR] [имя]
*/
import path from 'node:path';
import { config } from 'dotenv';
import { hash } from '@node-rs/argon2';
import { createDb } from '../index.ts';
config({ path: path.resolve(import.meta.dirname, '../../../../.env'), quiet: true });
const [login, role = 'OWNER', name] = process.argv.slice(2);
const password = process.env.ADMIN_PASSWORD;
if (!login || !password || !['OWNER', 'OPERATOR'].includes(role)) {
console.error('ADMIN_PASSWORD=… create-admin <логин> [OWNER|OPERATOR] [имя]');
process.exit(1);
}
const db = createDb();
const email = login.toLowerCase();
const passwordHash = await hash(password);
await db.adminUser.upsert({
where: { email },
create: { email, name: name ?? login, role: role as 'OWNER' | 'OPERATOR', roles: role === 'OWNER' ? ['SUPERADMIN'] : ['SUPPORT', 'CONCIERGE'], passwordHash },
update: { passwordHash, role: role as 'OWNER' | 'OPERATOR', active: true },
});
console.log(`Администратор ${email} (${role}) готов. 2FA настраивается при первом входе.`);
await db.$disconnect();
+26
View File
@@ -0,0 +1,26 @@
/**
* Сброс 2FA администратора (потерян телефон и т.п.). При следующем входе админка покажет QR заново.
*
* pnpm --filter @barev/db reset-2fa owner@barev.local
*/
import path from 'node:path';
import { config } from 'dotenv';
import { createDb } from '../index.ts';
config({ path: path.resolve(import.meta.dirname, '../../../../.env'), quiet: true });
const email = process.argv[2]?.toLowerCase();
if (!email) {
console.error('Укажите email: pnpm --filter @barev/db reset-2fa <email>');
process.exit(1);
}
const db = createDb();
const admin = await db.adminUser.findUnique({ where: { email } });
if (!admin) {
console.error(`Администратор ${email} не найден`);
process.exit(1);
}
await db.adminUser.update({ where: { id: admin.id }, data: { totpEnabled: false, totpSecret: null } });
await db.adminSession.deleteMany({ where: { adminUserId: admin.id } });
console.log(`2FA для ${email} сброшена. При входе появится QR-код для настройки.`);
await db.$disconnect();
+117
View File
@@ -0,0 +1,117 @@
/**
* Синхронизация каталога услуг с кодом (packages/core/src/catalog.ts) без потери настроек владельца.
* Запускается при каждом старте контейнера, идемпотентна:
* - форма заказа (paramsSchema) и чек-лист берутся из кода — они связаны с переводами и логикой;
* - цены: значения, уже заданные в админке, сохраняются; новые поля и доп. услуги добавляются из кода;
* - новые услуги каталога создаются; существующие направления не трогаются;
* - районы Еревана создаются, если их ещё нет (без них нельзя оформить заказ с адресом).
*
* pnpm --filter @barev/db sync-catalog
*/
import path from 'node:path';
import { config } from 'dotenv';
import { CATALOG, DEFAULT_CATEGORY_DISCOUNTS, DEFAULT_ELITE_DISCOUNTS, flatPercentRules, pricingSchema, YEREVAN_DISTRICTS, type Pricing } from '@barev/core';
import type { Db } from '../index.ts';
import { createDb } from '../index.ts';
config({ path: path.resolve(import.meta.dirname, '../../../../.env'), quiet: true });
/** Цены из кода, поверх — сохранённые владельцем значения для тех же ключей */
export function mergePricing(fromCode: Pricing, stored: unknown): Pricing {
const parsed = pricingSchema.safeParse(stored);
if (!parsed.success || parsed.data.kind !== fromCode.kind) return fromCode;
const old = parsed.data;
if (fromCode.kind === 'fixed' || old.kind === 'fixed') return { ...fromCode, ...old } as Pricing;
const oldRaw = (stored ?? {}) as { tiers?: Record<string, Record<string, unknown>>; extras?: Record<string, unknown> };
const tiers = Object.fromEntries(
Object.entries(fromCode.tiers).map(([variant, tier]) => {
const saved = oldRaw.tiers?.[variant] ?? {};
// поля, которых в БД ещё не было, берём из кода (а не дефолт схемы 0)
const merged = { ...tier };
for (const k of Object.keys(tier) as (keyof typeof tier)[]) if (typeof saved[k] === 'number') merged[k] = saved[k] as number;
return [variant, merged];
}),
);
const extras = Object.fromEntries(
Object.entries(fromCode.extras).map(([code, e]) => [code, oldRaw.extras?.[code] ? { ...e, ...old.extras[code], unit: e.unit } : e]),
);
return { ...fromCode, tiers, extras };
}
/** Пресеты ставок клинеров по умолчанию (заглушки — владелец меняет в админке). Создаются, только если пресетов ещё нет. */
export async function ensureDefaultPresets(db: Db) {
if ((await db.payoutPreset.count({ where: { role: 'CLEANER' } })) > 0) return 0;
const cleaning = CATALOG.find((s) => s.code === 'cleaning')!;
const variants = cleaning.pricing.kind === 'rooms' ? Object.keys(cleaning.pricing.tiers) : [];
const extras = cleaning.pricing.kind === 'rooms' ? Object.keys(cleaning.pricing.extras) : [];
const presets = [
{ name: 'Джун', pct: 50 },
{ name: 'Мидл', pct: 60 },
{ name: 'Прайват', pct: 70 },
];
for (const [i, p] of presets.entries()) {
await db.payoutPreset.create({ data: { name: p.name, role: 'CLEANER', sortOrder: i, rules: flatPercentRules(p.pct, variants, extras) } });
}
return presets.length;
}
/** Районы из кода; порядок сортировки не перезаписывается, если район уже есть */
export async function ensureDistricts(db: Db) {
const existing = new Set((await db.district.findMany({ select: { code: true } })).map((d) => d.code));
const missing = YEREVAN_DISTRICTS.map((code, i) => ({ code, sortOrder: i })).filter((d) => !existing.has(d.code));
if (missing.length) await db.district.createMany({ data: missing });
return missing.length;
}
async function main() {
const db = createDb();
let created = 0;
let updated = 0;
for (const [i, s] of CATALOG.entries()) {
const category = await db.category.upsert({
where: { code: s.category },
create: { code: s.category, barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS[s.category], eliteDiscountPct: DEFAULT_ELITE_DISCOUNTS[s.category] },
update: {},
});
const existing = await db.service.findUnique({ where: { code: s.code } });
if (!existing) {
await db.service.create({
data: {
code: s.code,
categoryId: category.id,
flow: s.flow,
providerRole: s.providerRole,
pricing: s.pricing,
paramsSchema: s.paramsSchema,
slotConfig: s.slotConfig,
isOnline: s.isOnline,
active: s.stage === 1,
sortOrder: i,
},
});
created++;
} else {
await db.service.update({
where: { id: existing.id },
data: { paramsSchema: s.paramsSchema, pricing: mergePricing(s.pricing, existing.pricing) },
});
updated++;
}
const service = await db.service.findUniqueOrThrow({ where: { code: s.code } });
for (const [j, c] of s.checklist.entries()) {
await db.checklistItem.upsert({
where: { serviceId_code: { serviceId: service.id, code: c.code } },
create: { serviceId: service.id, code: c.code, requiresPhoto: !!c.requiresPhoto, sortOrder: j },
update: { requiresPhoto: !!c.requiresPhoto, sortOrder: j },
});
}
}
const presets = await ensureDefaultPresets(db);
const districts = await ensureDistricts(db);
console.log(
`Каталог синхронизирован: создано ${created}, обновлено ${updated}${presets ? `, пресетов ставок: ${presets}` : ''}${districts ? `, районов: ${districts}` : ''}`,
);
await db.$disconnect();
}
if (import.meta.url === `file://${process.argv[1]}`) await main();
+10 -2
View File
@@ -10,6 +10,7 @@ import { hash } from '@node-rs/argon2';
import {
CATALOG,
DEFAULT_CATEGORY_DISCOUNTS,
DEFAULT_ELITE_DISCOUNTS,
DEFAULT_SETTINGS,
YEREVAN_DISTRICTS,
agencyCommission,
@@ -20,6 +21,7 @@ import {
type CategoryCode,
} from '@barev/core';
import { createDb, type Locale, type OrderStatus } from '../index.ts';
import { ensureDefaultPresets } from '../scripts/sync-catalog.ts';
config({ path: path.resolve(import.meta.dirname, '../../../../.env'), quiet: true });
@@ -71,7 +73,7 @@ async function main() {
const categoryOrder: CategoryCode[] = ['HOME', 'DOCS', 'LEARNING', 'PERKS'];
const categories: Record<string, string> = {};
for (const [i, code] of categoryOrder.entries()) {
const c = await db.category.create({ data: { code, sortOrder: i, barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS[code] } });
const c = await db.category.create({ data: { code, sortOrder: i, barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS[code], eliteDiscountPct: DEFAULT_ELITE_DISCOUNTS[code] } });
categories[code] = c.id;
}
const services: Record<string, { id: string }> = {};
@@ -102,6 +104,10 @@ async function main() {
data: { code: 'transfer', sortOrder: 1, name: { ru: 'Перевод на карту', en: 'Card transfer', zh: '银行卡转账', hy: 'Փոխանցում քարտին', es: 'Transferencia a tarjeta' } },
});
// ── пресеты ставок клинеров ──
await ensureDefaultPresets(db);
const middle = await db.payoutPreset.findFirstOrThrow({ where: { name: 'Мидл' } });
// ── агентства ──
const domik = await db.agency.create({ data: { code: 'DOMIK', name: 'Domik Realty', commissionPct: 10, contact: '+374 00 000000' } });
await db.agency.create({ data: { code: 'YERHOME', name: 'Yerevan Home', commissionPct: 10 } });
@@ -135,6 +141,7 @@ async function main() {
districts: (keyof typeof districts)[];
companyId?: string;
rate?: { type: 'FIXED' | 'PERCENT'; value: number };
presetId?: string;
onShift?: boolean;
rating?: [number, number];
schedule?: typeof workweek;
@@ -150,6 +157,7 @@ async function main() {
languages: p.languages,
rateType: p.rate?.type ?? 'PERCENT',
rateValue: p.rate?.value ?? 70,
payoutPresetId: p.presetId,
onShift: p.onShift ?? true,
ratingSum: p.rating?.[0] ?? 0,
ratingCount: p.rating?.[1] ?? 0,
@@ -160,7 +168,7 @@ async function main() {
});
}
const maria = await provider({ tg: SEED_TG.cleanerMaria, name: 'Мария Петросян', role: 'CLEANER', languages: ['ru', 'hy', 'en'], districts: ['kentron', 'arabkir'], rating: [47, 10], invite: 'PRO-MARIA' });
const maria = await provider({ tg: SEED_TG.cleanerMaria, name: 'Мария Петросян', role: 'CLEANER', languages: ['ru', 'hy', 'en'], districts: ['kentron', 'arabkir'], rating: [47, 10], presetId: middle.id, invite: 'PRO-MARIA' });
const ani = await provider({ tg: SEED_TG.cleanerAni, name: 'Ани Саргсян', role: 'CLEANER', languages: ['hy', 'ru'], districts: ['shengavit', 'erebuni', 'malatia_sebastia'], rate: { type: 'FIXED', value: 9000 }, rating: [22, 5], invite: 'PRO-ANI' });
const elena = await provider({ tg: SEED_TG.cleanerElena, name: 'Elena Gómez', role: 'CLEANER', languages: ['es', 'en', 'ru'], districts: ['kentron', 'arabkir', 'nor_nork'], schedule: everyday, invite: 'PRO-ELENA' });
await provider({ tg: SEED_TG.driverArman, name: 'Арман Мкртчян', role: 'DRIVER', languages: ['ru', 'hy', 'en'], districts: ['kentron'], schedule: everyday, rate: { type: 'FIXED', value: 7000 }, invite: 'PRO-ARMAN' });