
🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
#!/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<MigrationStats> {
|
||
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<string, string>()
|
||
|
||
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<string> {
|
||
// Проверяем есть ли уже 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<void> {
|
||
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<void> {
|
||
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 } |