#!/usr/bin/env tsx /** * СКРИПТ МИГРАЦИИ V1 → V2 INVENTORY SYSTEM * * Безопасно переносит данные из старой системы Supply в новую FulfillmentConsumableInventory * с возможностью отката и детальной отчетностью */ import { PrismaClient } from '@prisma/client' const prisma = new PrismaClient() interface MigrationStats { totalV1Records: number fulfillmentSupplies: number migratedRecords: number skippedRecords: number errors: string[] warnings: string[] } interface V1SupplyRecord { id: string name: string description: string | null price: any // Decimal quantity: number unit: string category: string | null status: string date: Date supplier: string minStock: number currentStock: number organizationId: string type: string | null createdAt: Date updatedAt: Date } const MIGRATION_LOG_PREFIX = '[V1→V2 MIGRATION]' /** * Главная функция миграции */ async function migrateV1ToV2Inventory(dryRun = true): Promise { const stats: MigrationStats = { totalV1Records: 0, fulfillmentSupplies: 0, migratedRecords: 0, skippedRecords: 0, errors: [], warnings: [], } console.log(`${MIGRATION_LOG_PREFIX} Starting migration (DRY RUN: ${dryRun})`) console.log(`${MIGRATION_LOG_PREFIX} Timestamp: ${new Date().toISOString()}`) try { // Получаем все записи из старой системы const v1Supplies = await prisma.supply.findMany({ include: { organization: true, }, orderBy: { createdAt: 'asc', }, }) stats.totalV1Records = v1Supplies.length console.log(`${MIGRATION_LOG_PREFIX} Found ${stats.totalV1Records} V1 Supply records`) // Фильтруем только расходники фулфилмента const fulfillmentSupplies = v1Supplies.filter(supply => supply.organization?.type === 'FULFILLMENT' && supply.type === 'FULFILLMENT_CONSUMABLES' ) stats.fulfillmentSupplies = fulfillmentSupplies.length console.log(`${MIGRATION_LOG_PREFIX} Found ${stats.fulfillmentSupplies} Fulfillment consumables to migrate`) if (stats.fulfillmentSupplies === 0) { console.log(`${MIGRATION_LOG_PREFIX} No fulfillment supplies to migrate`) return stats } // Создаем соответствующие Product записи для каждого Supply (если еще нет) const productMap = new Map() for (const supply of fulfillmentSupplies) { try { const productId = await ensureProductExists(supply, dryRun) productMap.set(supply.id, productId) } catch (error) { const errorMsg = `Failed to create/find product for supply ${supply.id}: ${error}` stats.errors.push(errorMsg) console.error(`${MIGRATION_LOG_PREFIX} ERROR: ${errorMsg}`) } } console.log(`${MIGRATION_LOG_PREFIX} Created/verified ${productMap.size} products`) // Мигрируем каждую запись Supply → FulfillmentConsumableInventory for (const supply of fulfillmentSupplies) { try { const productId = productMap.get(supply.id) if (!productId) { stats.skippedRecords++ continue } await migrateSupplyRecord(supply, productId, dryRun) stats.migratedRecords++ // Логируем прогресс каждые 10 записей if (stats.migratedRecords % 10 === 0) { console.log(`${MIGRATION_LOG_PREFIX} Progress: ${stats.migratedRecords}/${fulfillmentSupplies.length}`) } } catch (error) { const errorMsg = `Failed to migrate supply ${supply.id}: ${error}` stats.errors.push(errorMsg) console.error(`${MIGRATION_LOG_PREFIX} ERROR: ${errorMsg}`) stats.skippedRecords++ } } } catch (error) { const errorMsg = `Migration failed: ${error}` stats.errors.push(errorMsg) console.error(`${MIGRATION_LOG_PREFIX} CRITICAL ERROR: ${errorMsg}`) } // Финальная отчетность printMigrationReport(stats, dryRun) return stats } /** * Создает Product запись для Supply, если еще не существует */ async function ensureProductExists(supply: V1SupplyRecord, dryRun: boolean): Promise { // Проверяем есть ли уже Product с таким названием и организацией const existingProduct = await prisma.product.findFirst({ where: { organizationId: supply.organizationId, name: supply.name, type: 'CONSUMABLE', }, }) if (existingProduct) { return existingProduct.id } if (dryRun) { console.log(`${MIGRATION_LOG_PREFIX} [DRY RUN] Would create product: ${supply.name}`) return `mock-product-id-${supply.id}` } // Создаем новый Product на основе Supply данных const newProduct = await prisma.product.create({ data: { name: supply.name, article: `MIGRATED-${supply.id.slice(-8)}`, // Уникальный артикул description: supply.description || `Мигрировано из V1 Supply ${supply.id}`, price: supply.price || 0, unit: supply.unit || 'шт', category: supply.category || 'Расходники', type: 'CONSUMABLE', organizationId: supply.organizationId, quantity: 0, // В Product quantity означает что-то другое imageUrl: null, }, }) console.log(`${MIGRATION_LOG_PREFIX} Created product: ${newProduct.name} (${newProduct.id})`) return newProduct.id } /** * Мигрирует одну запись Supply в FulfillmentConsumableInventory */ async function migrateSupplyRecord(supply: V1SupplyRecord, productId: string, dryRun: boolean): Promise { if (dryRun) { console.log(`${MIGRATION_LOG_PREFIX} [DRY RUN] Would migrate: ${supply.name} (${supply.currentStock} units)`) return } // Проверяем не мигрировали ли уже эту запись const existingInventory = await prisma.fulfillmentConsumableInventory.findUnique({ where: { fulfillmentCenterId_productId: { fulfillmentCenterId: supply.organizationId, productId: productId, }, }, }) if (existingInventory) { console.log(`${MIGRATION_LOG_PREFIX} WARNING: Inventory already exists for ${supply.name}, skipping`) return } // Создаем запись в новой системе инвентаря const inventory = await prisma.fulfillmentConsumableInventory.create({ data: { fulfillmentCenterId: supply.organizationId, productId: productId, // Складские данные из V1 currentStock: supply.currentStock, minStock: supply.minStock, maxStock: null, // В V1 не было reservedStock: 0, // В V1 не было totalReceived: supply.quantity, // Приблизительно totalShipped: Math.max(0, supply.quantity - supply.currentStock), // Приблизительно // Цены averageCost: parseFloat(supply.price?.toString() || '0'), resalePrice: null, // В V1 не было // Метаданные lastSupplyDate: supply.date, lastUsageDate: null, // В V1 не было точной информации notes: `Мигрировано из V1 Supply ${supply.id} (${supply.status})`, // Временные метки сохраняем из V1 createdAt: supply.createdAt, updatedAt: supply.updatedAt, }, }) console.log(`${MIGRATION_LOG_PREFIX} ✅ Migrated: ${supply.name} → ${inventory.id}`) } /** * Печатает детальный отчет о миграции */ function printMigrationReport(stats: MigrationStats, dryRun: boolean): void { console.log('\n' + '='.repeat(60)) console.log(`${MIGRATION_LOG_PREFIX} MIGRATION REPORT`) console.log('='.repeat(60)) console.log(`Mode: ${dryRun ? 'DRY RUN' : 'PRODUCTION'}`) console.log(`Timestamp: ${new Date().toISOString()}`) console.log('') console.log('📊 STATISTICS:') console.log(` Total V1 records found: ${stats.totalV1Records}`) console.log(` Fulfillment supplies: ${stats.fulfillmentSupplies}`) console.log(` Successfully migrated: ${stats.migratedRecords}`) console.log(` Skipped records: ${stats.skippedRecords}`) console.log(` Errors encountered: ${stats.errors.length}`) console.log('') if (stats.errors.length > 0) { console.log('❌ ERRORS:') stats.errors.forEach((error, index) => { console.log(` ${index + 1}. ${error}`) }) console.log('') } if (stats.warnings.length > 0) { console.log('⚠️ WARNINGS:') stats.warnings.forEach((warning, index) => { console.log(` ${index + 1}. ${warning}`) }) console.log('') } if (dryRun) { console.log('🔄 TO RUN ACTUAL MIGRATION:') console.log(' npm run migrate:v1-to-v2 --production') } else { console.log('✅ MIGRATION COMPLETED!') console.log('🔄 TO VERIFY RESULTS:') console.log(' Check FulfillmentConsumableInventory table') console.log(' Verify counts match expectations') } console.log('='.repeat(60)) } /** * Функция отката миграции (осторожно!) */ async function rollbackMigration(): Promise { console.log(`${MIGRATION_LOG_PREFIX} ⚠️ STARTING ROLLBACK`) // Удаляем все мигрированные записи const deleted = await prisma.fulfillmentConsumableInventory.deleteMany({ where: { notes: { contains: 'Мигрировано из V1 Supply' } } }) console.log(`${MIGRATION_LOG_PREFIX} 🗑️ Deleted ${deleted.count} migrated inventory records`) // Удаляем мигрированные Products (осторожно!) const deletedProducts = await prisma.product.deleteMany({ where: { article: { startsWith: 'MIGRATED-' } } }) console.log(`${MIGRATION_LOG_PREFIX} 🗑️ Deleted ${deletedProducts.count} migrated product records`) console.log(`${MIGRATION_LOG_PREFIX} ✅ Rollback completed`) } /** * CLI интерфейс */ async function main() { const args = process.argv.slice(2) const isProduction = args.includes('--production') const isRollback = args.includes('--rollback') if (isRollback) { if (!isProduction) { console.log('❌ Rollback requires --production flag for safety') process.exit(1) } console.log('⚠️ You are about to rollback the V1→V2 migration!') console.log('This will DELETE all migrated data!') console.log('Press Ctrl+C to cancel...') // Ждем 5 секунд await new Promise(resolve => setTimeout(resolve, 5000)) await rollbackMigration() process.exit(0) } const dryRun = !isProduction if (dryRun) { console.log('🔍 Running in DRY RUN mode (no actual changes)') console.log('📝 Add --production flag to run actual migration') } else { console.log('⚠️ Running in PRODUCTION mode (will make changes)') console.log('Press Ctrl+C to cancel...') // Ждем 3 секунды в production режиме await new Promise(resolve => setTimeout(resolve, 3000)) } try { const stats = await migrateV1ToV2Inventory(dryRun) if (stats.errors.length > 0) { console.error(`${MIGRATION_LOG_PREFIX} Migration completed with errors`) process.exit(1) } console.log(`${MIGRATION_LOG_PREFIX} Migration completed successfully`) process.exit(0) } catch (error) { console.error(`${MIGRATION_LOG_PREFIX} Migration failed:`, error) process.exit(1) } finally { await prisma.$disconnect() } } // Запускаем только если скрипт вызван напрямую (ES modules) if (import.meta.url === `file://${process.argv[1]}`) { main().catch(console.error) } export { migrateV1ToV2Inventory, rollbackMigration }