Files
BarevClub/packages/db/src/seed/index.ts
T

360 lines
18 KiB
TypeScript
Raw Blame History

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