feat: модульная архитектура sidebar и улучшения навигации

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Veronika Smirnova
2025-08-30 15:51:41 +03:00
parent 8391f40e87
commit b40ac083ab
128 changed files with 9366 additions and 17283 deletions

View File

@ -0,0 +1,377 @@
#!/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 }