diff --git a/package.json b/package.json index 8b1d1e3..5965a7b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate deploy", "prisma:push": "prisma db push", - "seed": "node scripts/seed.js" + "seed": "node scripts/seed.js", + "seed:demo": "node scripts/seed-demo.js" }, "keywords": [], "author": "", diff --git a/scripts/seed-demo.js b/scripts/seed-demo.js new file mode 100644 index 0000000..b80bb26 --- /dev/null +++ b/scripts/seed-demo.js @@ -0,0 +1,534 @@ +import 'dotenv/config'; + +import { prisma } from '../src/prisma-client.js'; + +const MANAGER_EMAIL = 'manager@fregat.local'; +const DEMO_EMAIL_DOMAIN = 'demo.fregat.local'; +const DEMO_ORDER_PREFIX = 'DBG-'; +const DEMO_CLIENT_COUNT = Number.parseInt(process.env.DEMO_CLIENTS ?? '20', 10); +const DEMO_ORDER_COUNT = Number.parseInt(process.env.DEMO_ORDERS ?? '40', 10); + +const FIRST_NAMES = [ + 'Алексей', 'Мария', 'Ирина', 'Дмитрий', 'Светлана', + 'Павел', 'Ольга', 'Иван', 'Наталья', 'Егор', + 'Виктория', 'Максим', 'Елена', 'Роман', 'Анна', + 'Кирилл', 'Юлия', 'Андрей', 'Татьяна', 'Сергей', +]; + +const LAST_NAMES = [ + 'Иванов', 'Петрова', 'Смирнова', 'Козлов', 'Васильева', + 'Федоров', 'Морозова', 'Захаров', 'Орлова', 'Новиков', + 'Романова', 'Соколов', 'Беляева', 'Громов', 'Крылова', + 'Титов', 'Борисова', 'Попов', 'Лебедева', 'Макаров', +]; + +const CITIES = [ + 'Москва', + 'Санкт-Петербург', + 'Казань', + 'Екатеринбург', + 'Новосибирск', + 'Нижний Новгород', + 'Самара', + 'Краснодар', + 'Ростов-на-Дону', + 'Воронеж', +]; + +const STREETS = [ + 'Ленинградский проспект', + 'Кубинская улица', + 'улица Родины', + 'Промышленная улица', + 'улица Победы', + 'Складской проезд', + 'Транспортная улица', + 'улица Энергетиков', + 'Рабочая улица', + 'Индустриальный проспект', +]; + +const COMPANY_PREFIXES = [ + 'ТД', 'ПК', 'Логистик', 'Сервис', 'Группа', + 'Снаб', 'Пром', 'Регион', 'Партнер', 'Трейд', +]; + +const COMPANY_SUFFIXES = [ + 'Пласт', 'Пак', 'Транс', 'Маркет', 'Снабжение', + 'Лайн', 'Система', 'Поставка', 'Ритейл', 'Ресурс', +]; + +const ORDER_STATUS_CYCLE = [ + 'NEW', + 'MANAGER_PROCESSING', + 'WAITING_DOUBLE_CONFIRM', + 'CONFIRMED', + 'IN_PROGRESS', + 'COMPLETED', +]; + +function formatIndex(index) { + return String(index).padStart(2, '0'); +} + +function buildClientEmail(index) { + return `client${formatIndex(index)}@${DEMO_EMAIL_DOMAIN}`; +} + +function buildInn(index) { + return `7702${String(index).padStart(6, '0')}`; +} + +function buildOgrn(index) { + return `102770${String(index).padStart(7, '0')}`; +} + +function buildBik(index) { + return `0445${String(10000 + index).slice(-5)}`; +} + +function buildAccount(prefix, index) { + return `${prefix}${String(100000000000000000 + index).slice(-18)}`; +} + +function atMiddayDaysAgo(daysAgo) { + const date = new Date(); + date.setHours(12, 0, 0, 0); + date.setDate(date.getDate() - daysAgo); + return date; +} + +function addHours(date, hours) { + return new Date(date.getTime() + (hours * 60 * 60 * 1000)); +} + +function toMoney(value) { + return value.toFixed(2); +} + +function createOrderTimeline(status, createdAt) { + const steps = [ + { status: 'NEW', note: '[demo] Заказ создан клиентом.' }, + { status: 'MANAGER_PROCESSING', note: '[demo] Менеджер взял заказ в работу.' }, + { status: 'WAITING_DOUBLE_CONFIRM', note: '[demo] Согласование условий и цены.' }, + { status: 'CONFIRMED', note: '[demo] Стороны подтвердили заказ.' }, + { status: 'IN_PROGRESS', note: '[demo] Заказ передан в исполнение.' }, + { status: 'COMPLETED', note: '[demo] Заказ доставлен клиенту.' }, + ]; + + const targetIndex = steps.findIndex((step) => step.status === status); + if (targetIndex === -1) { + return [steps[0]]; + } + + return steps.slice(0, targetIndex + 1).map((step, index) => ({ + ...step, + createdAt: addHours(createdAt, index * 6), + })); +} + +function orderKindForIndex(index) { + return index % 5 === 0 ? 'CALCULATION' : 'READY'; +} + +function orderStatusForIndex(index) { + return ORDER_STATUS_CYCLE[index % ORDER_STATUS_CYCLE.length]; +} + +function companyNameForIndex(index) { + const prefix = COMPANY_PREFIXES[index % COMPANY_PREFIXES.length]; + const suffix = COMPANY_SUFFIXES[index % COMPANY_SUFFIXES.length]; + return `${prefix} ${suffix} ${formatIndex(index)}`; +} + +function fullNameForIndex(index) { + return `${FIRST_NAMES[(index - 1) % FIRST_NAMES.length]} ${LAST_NAMES[(index - 1) % LAST_NAMES.length]}`; +} + +async function upsertClient(index) { + const companyName = companyNameForIndex(index); + const company = await prisma.company.upsert({ + where: { inn: buildInn(index) }, + update: { name: companyName }, + create: { + name: companyName, + inn: buildInn(index), + }, + }); + + const user = await prisma.user.upsert({ + where: { email: buildClientEmail(index) }, + update: { + fullName: fullNameForIndex(index), + role: 'CLIENT', + companyId: company.id, + }, + create: { + email: buildClientEmail(index), + fullName: fullNameForIndex(index), + role: 'CLIENT', + companyId: company.id, + }, + }); + + await prisma.counterpartyProfile.upsert({ + where: { userId: user.id }, + update: { + companyName, + companyFullName: `${companyName}, общество с ограниченной ответственностью`, + inn: buildInn(index), + kpp: `7702${String(2000 + index).slice(-4)}`, + ogrn: buildOgrn(index), + legalAddress: `${CITIES[(index - 1) % CITIES.length]}, ${STREETS[(index - 1) % STREETS.length]}, ${10 + index}`, + bankName: 'АО Тест Банк', + bik: buildBik(index), + correspondentAccount: buildAccount('30101', index), + checkingAccount: buildAccount('40702', index), + signerFullName: fullNameForIndex(index), + signerPosition: 'Генеральный директор', + signerBasis: 'Устав', + }, + create: { + userId: user.id, + companyName, + companyFullName: `${companyName}, общество с ограниченной ответственностью`, + inn: buildInn(index), + kpp: `7702${String(2000 + index).slice(-4)}`, + ogrn: buildOgrn(index), + legalAddress: `${CITIES[(index - 1) % CITIES.length]}, ${STREETS[(index - 1) % STREETS.length]}, ${10 + index}`, + bankName: 'АО Тест Банк', + bik: buildBik(index), + correspondentAccount: buildAccount('30101', index), + checkingAccount: buildAccount('40702', index), + signerFullName: fullNameForIndex(index), + signerPosition: 'Генеральный директор', + signerBasis: 'Устав', + }, + }); + + const addressLabel = 'Основной адрес'; + const addressValue = `${CITIES[(index - 1) % CITIES.length]}, ${STREETS[(index - 1) % STREETS.length]}, ${20 + index}`; + const existingAddress = await prisma.deliveryAddress.findFirst({ + where: { + userId: user.id, + label: addressLabel, + }, + }); + + const address = existingAddress + ? await prisma.deliveryAddress.update({ + where: { id: existingAddress.id }, + data: { + address: addressValue, + unrestrictedValue: addressValue, + }, + }) + : await prisma.deliveryAddress.create({ + data: { + userId: user.id, + label: addressLabel, + address: addressValue, + unrestrictedValue: addressValue, + }, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { + defaultDeliveryAddressId: address.id, + }, + }); + + return { user, address }; +} + +async function rebuildMessengerConnections(clients) { + await prisma.messengerConnection.deleteMany({ + where: { + userId: { + in: clients.map((entry) => entry.user.id), + }, + }, + }); + + for (const [index, client] of clients.entries()) { + const demoIndex = index + 1; + + if (demoIndex % 2 === 1) { + await prisma.messengerConnection.create({ + data: { + userId: client.user.id, + type: 'TELEGRAM', + channelId: `70000${demoIndex}`, + displayName: client.user.fullName, + username: `fregat_demo_${formatIndex(demoIndex)}`, + }, + }); + } + + if (demoIndex % 3 === 0) { + await prisma.messengerConnection.create({ + data: { + userId: client.user.id, + type: 'MAX', + channelId: `90000${demoIndex}`, + displayName: client.user.fullName, + username: `max_demo_${formatIndex(demoIndex)}`, + }, + }); + } + } +} + +async function cleanupDemoData(demoUserIds) { + const demoOrders = await prisma.order.findMany({ + where: { + OR: [ + { code: { startsWith: DEMO_ORDER_PREFIX } }, + { customerId: { in: demoUserIds } }, + ], + }, + select: { id: true }, + }); + + const demoOrderIds = demoOrders.map((order) => order.id); + + if (demoOrderIds.length) { + await prisma.orderStatusEvent.deleteMany({ + where: { orderId: { in: demoOrderIds } }, + }); + await prisma.orderItem.deleteMany({ + where: { orderId: { in: demoOrderIds } }, + }); + await prisma.bonusTransaction.deleteMany({ + where: { orderId: { in: demoOrderIds } }, + }); + await prisma.order.deleteMany({ + where: { id: { in: demoOrderIds } }, + }); + } + + await prisma.rewardWithdrawalRequest.deleteMany({ + where: { requesterId: { in: demoUserIds } }, + }); + + await prisma.bonusTransaction.deleteMany({ + where: { + userId: { in: demoUserIds }, + reason: { startsWith: '[demo]' }, + }, + }); + + await prisma.referralLink.deleteMany({ + where: { + OR: [ + { referrerId: { in: demoUserIds } }, + { refereeId: { in: demoUserIds } }, + ], + }, + }); +} + +async function createReferralLinks(managerId, clients) { + const links = []; + + for (let index = 0; index < Math.min(10, Math.floor(clients.length / 2)); index += 1) { + const referrer = clients[index].user; + const referee = clients[index + 10]?.user; + if (!referee) { + continue; + } + + const link = await prisma.referralLink.create({ + data: { + referrerId: referrer.id, + refereeId: referee.id, + createdById: managerId, + bonusPercent: toMoney(5 + (index % 4) * 2.5), + }, + }); + + links.push(link); + } + + return links; +} + +async function createOrders(managerId, clients, products, referralLinks) { + const referralByRefereeId = new Map(referralLinks.map((link) => [link.refereeId, link])); + let completedOrders = 0; + + for (let index = 1; index <= DEMO_ORDER_COUNT; index += 1) { + const client = clients[(index - 1) % clients.length]; + const createdAt = atMiddayDaysAgo(DEMO_ORDER_COUNT - index + 1); + const status = orderStatusForIndex(index - 1); + const kind = orderKindForIndex(index); + const itemCount = 1 + (index % 3); + const orderProducts = Array.from({ length: itemCount }, (_, itemIndex) => ( + products[(index + itemIndex * 3) % products.length] + )); + + const preparedItems = orderProducts.map((product, itemIndex) => { + const quantity = 5 + ((index + itemIndex * 2) % 18); + const unitPrice = 72 + ((index * 11 + itemIndex * 7) % 65); + return { + product, + quantity, + unitPrice, + lineTotal: quantity * unitPrice, + }; + }); + + const totalPrice = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0); + const deliveryFee = 1200 + (index % 5) * 350; + + const order = await prisma.order.create({ + data: { + code: `${DEMO_ORDER_PREFIX}${String(3000 + index)}`, + kind, + customerId: client.user.id, + managerId, + deliveryAddressId: client.address.id, + deliveryAddress: client.address.address, + deliveryTerms: index % 4 === 0 + ? 'Доставка до адреса клиента' + : 'Самовывоз со склада после подтверждения', + deliveryFee: toMoney(deliveryFee), + totalPrice: toMoney(totalPrice + deliveryFee), + status, + clientApproved: ['CONFIRMED', 'IN_PROGRESS', 'COMPLETED'].includes(status) ? true : null, + managerApproved: ['WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS', 'COMPLETED'].includes(status) ? true : null, + calculationPayload: kind === 'CALCULATION' + ? { + note: 'Демо-расчет для интерфейсов менеджера.', + requestedAt: createdAt.toISOString(), + } + : null, + createdAt, + updatedAt: addHours(createdAt, 12), + }, + }); + + for (const item of preparedItems) { + await prisma.orderItem.create({ + data: { + orderId: order.id, + productId: item.product.id, + productName: item.product.name, + quantity: item.quantity.toFixed(3), + unitPrice: toMoney(item.unitPrice), + createdAt, + }, + }); + } + + const timeline = createOrderTimeline(status, createdAt); + for (const event of timeline) { + await prisma.orderStatusEvent.create({ + data: { + orderId: order.id, + status: event.status, + actorUserId: managerId, + note: event.note, + createdAt: event.createdAt, + }, + }); + } + + if (status === 'COMPLETED') { + completedOrders += 1; + const referralLink = referralByRefereeId.get(client.user.id); + if (referralLink) { + const bonusAmount = Number((Number(totalPrice + deliveryFee) * Number(referralLink.bonusPercent) / 100).toFixed(2)); + await prisma.bonusTransaction.create({ + data: { + userId: referralLink.referrerId, + amount: toMoney(bonusAmount), + reason: `[demo] Бонус за заказ ${order.code}`, + orderId: order.id, + referralLinkId: referralLink.id, + createdAt: addHours(createdAt, 18), + }, + }); + } + } + } + + return completedOrders; +} + +async function createWithdrawalDrafts(clients) { + const candidates = clients.slice(0, 3); + + for (const [index, client] of candidates.entries()) { + await prisma.rewardWithdrawalRequest.create({ + data: { + requesterId: client.user.id, + amount: toMoney(1500 + index * 750), + status: 'PENDING', + reviewComment: '[demo] Тестовая заявка на вывод.', + }, + }); + } +} + +async function main() { + if (!Number.isFinite(DEMO_CLIENT_COUNT) || DEMO_CLIENT_COUNT < 2) { + throw new Error('DEMO_CLIENTS must be at least 2.'); + } + + if (!Number.isFinite(DEMO_ORDER_COUNT) || DEMO_ORDER_COUNT < 1) { + throw new Error('DEMO_ORDERS must be at least 1.'); + } + + const manager = await prisma.user.findUnique({ + where: { email: MANAGER_EMAIL }, + }); + + if (!manager) { + throw new Error(`Manager ${MANAGER_EMAIL} not found. Run npm run seed first.`); + } + + const products = await prisma.product.findMany({ + where: { isActive: true }, + orderBy: { sku: 'asc' }, + take: 24, + }); + + if (products.length < 6) { + throw new Error('Not enough active products. Run npm run seed first.'); + } + + const clients = []; + for (let index = 1; index <= DEMO_CLIENT_COUNT; index += 1) { + clients.push(await upsertClient(index)); + } + + const demoUserIds = clients.map((entry) => entry.user.id); + await cleanupDemoData(demoUserIds); + await rebuildMessengerConnections(clients); + + const referralLinks = await createReferralLinks(manager.id, clients); + const completedOrders = await createOrders(manager.id, clients, products, referralLinks); + await createWithdrawalDrafts(clients); + + const [usersCount, ordersCount] = await Promise.all([ + prisma.user.count({ + where: { + email: { endsWith: `@${DEMO_EMAIL_DOMAIN}` }, + }, + }), + prisma.order.count({ + where: { + code: { startsWith: DEMO_ORDER_PREFIX }, + }, + }), + ]); + + console.log(`Demo seed complete: ${usersCount} demo clients, ${ordersCount} demo orders, ${referralLinks.length} referral links, ${completedOrders} completed orders.`); +} + +await main() + .finally(async () => { + await prisma.$disconnect(); + });