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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-17 19:45:51 +04:00
commit 45d7512d2b
64 files changed
+9483

No files matched your search

+21
View File
@@ -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;
}
+351
View File
@@ -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());