/** * Seed для локальной разработки и staging: справочники, исполнители, компания, агентства, * тестовые клиенты и заказы во всех колонках канбана. Полностью пересоздаёт данные. * * pnpm db:seed */ import path from 'node:path'; import { config } from 'dotenv'; import { hash } from '@node-rs/argon2'; import { CATALOG, DEFAULT_CATEGORY_DISCOUNTS, DEFAULT_ELITE_DISCOUNTS, DEFAULT_SETTINGS, YEREVAN_DISTRICTS, agencyCommission, fromYerevan, quote, toYerevan, yerevanMonthKey, 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 }); if (process.env.NODE_ENV === 'production' && process.env.SEED_FORCE !== '1') { console.error('Seed пересоздаёт данные. В production запустите с SEED_FORCE=1, если уверены.'); process.exit(1); } const db = createDb(); const HOUR = 3600_000; /** Telegram ID тестовых пользователей — ими можно войти в Mini App через pnpm dev:initdata */ export const SEED_TG = { clientAnna: 100000001n, clientJohn: 100000002n, clientLi: 100000003n, cleanerMaria: 200000001n, cleanerAni: 200000002n, cleanerElena: 200000003n, companyLusine: 200000011n, driverArman: 200000021n, dispatcherKaren: 300000001n, } as const; async function wipe() { const tables = await db.$queryRaw<{ tablename: string }[]>` SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> '_prisma_migrations'`; const list = tables.map((t) => `"public"."${t.tablename}"`).join(', '); if (list) await db.$executeRawUnsafe(`TRUNCATE ${list} RESTART IDENTITY CASCADE`); } /** Ереванское время: сегодня + dayOffset, hh:mm */ function at(dayOffset: number, hour: number, minute = 0): Date { const p = toYerevan(new Date(Date.now() + dayOffset * 24 * HOUR)); return fromYerevan(p.year, p.month, p.day, hour * 60 + minute); } async function main() { await wipe(); // ── настройки ── await db.setting.createMany({ data: Object.entries(DEFAULT_SETTINGS).map(([key, value]) => ({ key, value })) }); // ── районы ── await db.district.createMany({ data: YEREVAN_DISTRICTS.map((code, i) => ({ code, sortOrder: i })) }); const districts = Object.fromEntries((await db.district.findMany()).map((d) => [d.code, d.id])) as Record<(typeof YEREVAN_DISTRICTS)[number], string>; // ── направления и услуги ── const categoryOrder: CategoryCode[] = ['HOME', 'DOCS', 'LEARNING', 'PERKS']; const categories: Record = {}; for (const [i, code] of categoryOrder.entries()) { 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 = {}; for (const [i, s] of CATALOG.entries()) { services[s.code] = await db.service.create({ data: { code: s.code, categoryId: categories[s.category]!, flow: s.flow, providerRole: s.providerRole, pricing: s.pricing, paramsSchema: s.paramsSchema, slotConfig: s.slotConfig, isOnline: s.isOnline, lateCancelFeePct: 30, active: s.stage === 1, sortOrder: i, checklist: { create: s.checklist.map((c, j) => ({ code: c.code, requiresPhoto: !!c.requiresPhoto, sortOrder: j })) }, }, }); } // ── способы оплаты (PAY-1) ── const cash = await db.paymentMethod.create({ data: { code: 'cash', sortOrder: 0, name: { ru: 'Наличными исполнителю', en: 'Cash to the specialist', zh: '现金支付给服务人员', hy: 'Կանխիկ՝ կատարողին', es: 'En efectivo al especialista' } }, }); await db.paymentMethod.create({ 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 } }); await db.agency.create({ data: { code: 'OLDAGENCY', name: 'Закрытое агентство', active: false } }); // ── админка ── const owner = await db.adminUser.create({ data: { email: process.env.SEED_OWNER_EMAIL ?? 'owner@barev.local', name: 'Владелец', role: 'OWNER', passwordHash: await hash(process.env.SEED_OWNER_PASSWORD ?? 'barev-owner-123'), }, }); await db.adminUser.create({ data: { email: 'operator@barev.local', name: 'Оператор', role: 'OPERATOR', passwordHash: await hash('barev-operator-123') }, }); // ── пользователи Telegram ── const user = (telegramId: bigint, firstName: string, locale: Locale, lastName?: string) => db.user.create({ data: { telegramId, firstName, lastName, locale, username: `seed_${telegramId}` } }); // ── исполнители ── const workweek = [0, 1, 2, 3, 4, 5].map((weekday) => ({ weekday, startMinute: 9 * 60, endMinute: 20 * 60 })); const everyday = [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ weekday, startMinute: 8 * 60, endMinute: 21 * 60 })); async function provider(p: { tg?: bigint; name: string; role: 'CLEANER' | 'DRIVER'; languages: Locale[]; districts: (keyof typeof districts)[]; companyId?: string; rate?: { type: 'FIXED' | 'PERCENT'; value: number }; presetId?: string; onShift?: boolean; rating?: [number, number]; schedule?: typeof workweek; invite: string; }) { const u = p.tg ? await user(p.tg, p.name.split(' ')[0]!, p.languages[0]!) : null; return db.provider.create({ data: { userId: u?.id, name: p.name, role: p.role, companyId: p.companyId, 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, inviteCode: p.invite, districts: { create: p.districts.map((d) => ({ districtId: districts[d] })) }, schedule: { create: p.schedule ?? workweek }, }, }); } 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' }); const company = await db.providerCompany.create({ data: { name: 'Чисто Ереван', roles: ['CLEANER'], rateType: 'PERCENT', rateValue: 65, contactPhone: '+374 00 111111', inviteCode: 'CO-CHISTO' }, }); const karen = await user(SEED_TG.dispatcherKaren, 'Карен', 'ru'); await db.companyDispatcher.create({ data: { companyId: company.id, userId: karen.id } }); const lusine = await provider({ tg: SEED_TG.companyLusine, name: 'Лусине Акопян', role: 'CLEANER', languages: ['hy', 'ru'], districts: ['kentron', 'nor_nork', 'avan'], companyId: company.id, invite: 'PRO-LUSINE' }); await provider({ name: 'Гаяне Арутюнян', role: 'CLEANER', languages: ['ru', 'en'], districts: ['arabkir', 'ajapnyak', 'davtashen'], companyId: company.id, onShift: false, invite: 'PRO-GAYANE' }); // ── клиенты ── const now = new Date(); async function client(tg: bigint, name: string, locale: Locale, opts: { agency?: string; gift?: boolean; card: string; attachedMonthsAgo?: number }) { const u = await user(tg, name, locale); const attachedAt = opts.agency ? new Date(now.getTime() - (opts.attachedMonthsAgo ?? 0) * 30 * 24 * HOUR - HOUR) : undefined; const c = await db.client.create({ data: { userId: u.id, consentAt: now, consentVersion: DEFAULT_SETTINGS['consent.version'], agencyId: opts.agency, agencyAttachedAt: attachedAt, source: opts.agency ? 'deeplink' : 'organic', utm: opts.agency ? { utm_source: 'qr', utm_campaign: 'lease' } : undefined, cardCode: opts.card, giftUsedAt: opts.gift ? attachedAt : undefined, }, }); if (opts.gift) { await db.subscription.create({ data: { clientId: c.id, source: 'AGENCY_GIFT', startsAt: attachedAt!, endsAt: new Date(attachedAt!.getTime() + 30 * 24 * HOUR) }, }); } return c; } const anna = await client(SEED_TG.clientAnna, 'Анна', 'ru', { agency: domik.id, gift: true, card: 'ANNA2026' }); const john = await client(SEED_TG.clientJohn, 'John', 'en', { card: 'JOHN2026' }); const li = await client(SEED_TG.clientLi, '李明', 'zh', { agency: domik.id, gift: false, card: 'LIMING26' }); // ── заказы клининга ── const cleaning = CATALOG.find((s) => s.code === 'cleaning')!; const checklistCodes = cleaning.checklist; async function order(o: { client: { id: string }; locale: Locale; when: Date; status: OrderStatus; params: { cleaningType: string; rooms: number }; district: keyof typeof districts; address: string; providerId?: string; companyId?: string; discount: boolean; checked?: number; paid?: boolean; note?: string; history: [OrderStatus | null, OrderStatus, 'CLIENT' | 'SYSTEM' | 'PROVIDER' | 'ADMIN' | 'DISPATCHER', string?][]; }) { const q = quote(cleaning.pricing, o.params, o.discount ? DEFAULT_CATEGORY_DISCOUNTS.HOME : 0); const created = await db.order.create({ data: { clientId: o.client.id, serviceId: services.cleaning!.id, params: o.params, scheduledAt: o.when, durationMinutes: q.durationMinutes, addressText: o.address, districtId: districts[o.district], note: o.note, locale: o.locale, basePrice: q.basePrice, discountPct: q.discountPct, discountAmount: q.discountAmount, totalPrice: q.totalPrice, status: o.status, providerId: o.providerId, companyId: o.companyId, paymentMethodId: cash.id, paymentStatus: o.paid ? 'PAID' : 'UNPAID', paidAt: o.paid ? o.when : undefined, completedAt: o.status === 'DONE' ? new Date(o.when.getTime() + q.durationMinutes * 60_000) : undefined, cancelledAt: o.status === 'CANCELLED' ? now : undefined, checklist: { create: checklistCodes.map((c, i) => ({ code: c.code, requiresPhoto: !!c.requiresPhoto, sortOrder: i, checkedAt: i < (o.checked ?? 0) && !c.requiresPhoto ? now : undefined, })), }, history: { create: o.history.map(([from, to, actor, event], i) => ({ fromStatus: from, toStatus: to, actorType: actor, event, createdAt: new Date(now.getTime() - (o.history.length - i) * 600_000), })), }, }, }); return created; } // Новый, без исполнителя — колонка «Новые» await order({ client: anna, locale: 'ru', when: at(1, 11), status: 'NEW', params: { cleaningType: 'general', rooms: 2 }, district: 'kentron', address: 'ул. Пушкина, 10, кв. 15', discount: true, note: 'Есть кот, он дружелюбный', history: [[null, 'NEW', 'CLIENT']], }); // Предложен Марии, ждёт подтверждения — колонка «Назначены» const offered = await order({ client: john, locale: 'en', when: at(1, 15), status: 'NEW', params: { cleaningType: 'regular', rooms: 3 }, district: 'arabkir', address: 'Komitas Ave 25, apt 7', discount: false, history: [[null, 'NEW', 'CLIENT']], }); await db.orderOffer.create({ data: { orderId: offered.id, providerId: maria.id, status: 'OFFERED', expiresAt: new Date(now.getTime() + 15 * 60_000), createdById: owner.id } }); // Передан компании — «Назначен на компанию» const toCompany = await order({ client: li, locale: 'zh', when: at(2, 12), status: 'NEW', params: { cleaningType: 'renovation', rooms: 2 }, district: 'nor_nork', address: 'Gai Ave 3/1, apt 40', discount: false, history: [[null, 'NEW', 'CLIENT']], }); await db.order.update({ where: { id: toCompany.id }, data: { companyId: company.id } }); await db.orderOffer.create({ data: { orderId: toCompany.id, companyId: company.id, status: 'OFFERED', createdById: owner.id } }); // Назначен Марии на сегодня — для демонстрации шагов исполнителя const todayHour = Math.min(Math.max(toYerevan(now).hour + 2, 9), 17); await order({ client: anna, locale: 'ru', when: at(0, todayHour), status: 'ASSIGNED', params: { cleaningType: 'regular', rooms: 2 }, district: 'kentron', address: 'ул. Абовяна, 22, кв. 5', providerId: maria.id, discount: true, note: 'Домофон 5#', history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'SYSTEM', 'accepted']], }); // Идёт уборка у Елены — колонка «В работе» await order({ client: li, locale: 'zh', when: at(0, Math.max(9, todayHour - 2)), status: 'CLEANING', params: { cleaningType: 'regular', rooms: 1 }, district: 'arabkir', address: 'Baghramyan Ave 50, apt 12', providerId: elena.id, discount: false, checked: 2, history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'SYSTEM', 'accepted'], ['ASSIGNED', 'EN_ROUTE', 'PROVIDER', 'depart'], ['EN_ROUTE', 'EN_ROUTE', 'PROVIDER', 'arrived'], ['EN_ROUTE', 'CLEANING', 'PROVIDER', 'startCleaning']], }); // Назначен Ани на послезавтра await order({ client: john, locale: 'en', when: at(2, 10), status: 'ASSIGNED', params: { cleaningType: 'general', rooms: 1 }, district: 'shengavit', address: 'Arshakunyats Ave 8', providerId: ani.id, discount: false, history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'SYSTEM', 'accepted']], }); // Сотрудник компании — назначен диспетчером await order({ client: anna, locale: 'ru', when: at(3, 14), status: 'ASSIGNED', params: { cleaningType: 'regular', rooms: 4 }, district: 'kentron', address: 'ул. Туманяна, 5', providerId: lusine.id, companyId: company.id, discount: true, history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'DISPATCHER', 'dispatcherAssigned']], }); // Готов и оплачен — с вознаграждением агентству const done = await order({ client: anna, locale: 'ru', when: at(-3, 10), status: 'DONE', params: { cleaningType: 'general', rooms: 2 }, district: 'kentron', address: 'ул. Пушкина, 10, кв. 15', providerId: maria.id, discount: true, checked: 5, paid: true, history: [[null, 'NEW', 'CLIENT'], ['NEW', 'ASSIGNED', 'SYSTEM', 'accepted'], ['ASSIGNED', 'EN_ROUTE', 'PROVIDER', 'depart'], ['EN_ROUTE', 'CLEANING', 'PROVIDER', 'startCleaning'], ['CLEANING', 'DONE', 'PROVIDER', 'finishCleaning']], }); await db.payment.create({ data: { orderId: done.id, provider: 'manual', amount: done.totalPrice, status: 'PAID', markedById: owner.id } }); await db.agencyCommission.create({ data: { agencyId: domik.id, orderId: done.id, pct: domik.commissionPct, amount: agencyCommission(done.totalPrice, domik.commissionPct), period: yerevanMonthKey(done.scheduledAt) }, }); // Отменён клиентом await order({ client: john, locale: 'en', when: at(4, 9), status: 'CANCELLED', params: { cleaningType: 'regular', rooms: 1 }, district: 'kentron', address: 'Amiryan St 4', discount: false, history: [[null, 'NEW', 'CLIENT'], ['NEW', 'CANCELLED', 'CLIENT']], }); // ── партнёры (Этап 3, для каталога) ── await db.partner.createMany({ data: [ { category: 'cafe', name: 'Coffee Lab', offer: { ru: '−10% на кофе', en: '−10% on coffee' }, address: 'Abovyan St 3' }, { category: 'restaurant', name: 'Lavash', offer: { ru: 'Десерт в подарок', en: 'Free dessert' }, address: 'Tumanyan St 21' }, { category: 'other', name: 'Yerevan Books', offer: { ru: '−15% на книги', en: '−15% on books' }, address: 'Nalbandyan St 7' }, ], }); console.log('Seed готов.'); console.log(` Админка: ${owner.email} / ${process.env.SEED_OWNER_PASSWORD ?? 'barev-owner-123'} (владелец), operator@barev.local / barev-operator-123`); console.log(' Агентства: DOMIK, YERHOME (OLDAGENCY — неактивно)'); console.log(' Инвайты исполнителей: PRO-GAYANE (не привязан), компания CO-CHISTO'); console.log(' Telegram ID для pnpm dev:initdata:', Object.entries(SEED_TG).map(([k, v]) => `${k}=${v}`).join(', ')); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => db.$disconnect());