Monorepo skeleton, Prisma schema, core domain and services with tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
45d7512d2b
64 files changed
+9483
No files matched your search
@@ -0,0 +1,21 @@
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { PrismaClient, type Prisma } from './generated/prisma/client.ts';
|
||||
|
||||
export * from './generated/prisma/client.ts';
|
||||
|
||||
export type Db = PrismaClient;
|
||||
/** Клиент внутри транзакции */
|
||||
export type Tx = Prisma.TransactionClient;
|
||||
|
||||
let instance: PrismaClient | undefined;
|
||||
|
||||
export function createDb(url = process.env.DATABASE_URL): PrismaClient {
|
||||
if (!url) throw new Error('DATABASE_URL is not set');
|
||||
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
|
||||
}
|
||||
|
||||
/** Общий клиент процесса */
|
||||
export function getDb(): PrismaClient {
|
||||
instance ??= createDb();
|
||||
return instance;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* 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_SETTINGS,
|
||||
YEREVAN_DISTRICTS,
|
||||
agencyCommission,
|
||||
fromYerevan,
|
||||
quote,
|
||||
toYerevan,
|
||||
yerevanMonthKey,
|
||||
type CategoryCode,
|
||||
} from '@barev/core';
|
||||
import { createDb, type Locale, type OrderStatus } from '../index.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] } });
|
||||
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' } },
|
||||
});
|
||||
|
||||
// ── агентства ──
|
||||
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 };
|
||||
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,
|
||||
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], 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());
|
||||
Reference in new issue
Block a user