feat: завершить полную миграцию V1→V2 с модульной архитектурой и документацией

- Завершить миграцию фулфилмента на 100% V2 (удалить legacy компонент)
- Создать полную V2 систему для расходников селлера (SellerConsumableInventory)
- Автоматическое пополнение инвентаря при статусе DELIVERED
- Удалить весь код создания V1 Supply для расходников
- Исправить фильтрацию: расходники селлера только на странице consumables
- Исправить Organization.inn null ошибку с fallback значениями
- Создать документацию V2 систем и отчет о миграции
- Обновить import порядок для ESLint совместимости

BREAKING CHANGES: V1 система поставок расходников полностью удалена
This commit is contained in:
Veronika Smirnova
2025-09-01 00:11:48 +03:00
parent 3f0cc933fc
commit be891f5354
18 changed files with 1347 additions and 1520 deletions

View File

@ -10,14 +10,13 @@ import { MarketplaceService } from '@/services/marketplace-service'
import { SmsService } from '@/services/sms-service'
import { WildberriesService } from '@/services/wildberries-service'
import '@/lib/seed-init' // Автоматическая инициализация БД
// Импорт новых resolvers для системы поставок v2
import { fulfillmentConsumableV2Queries, fulfillmentConsumableV2Mutations } from './resolvers/fulfillment-consumables-v2'
import { fulfillmentConsumableV2Queries as fulfillmentConsumableV2QueriesRestored, fulfillmentConsumableV2Mutations as fulfillmentConsumableV2MutationsRestored } from './resolvers/fulfillment-consumables-v2-restored'
import { fulfillmentInventoryV2Queries } from './resolvers/fulfillment-inventory-v2'
import { fulfillmentConsumableV2Queries as fulfillmentConsumableV2QueriesRestored, fulfillmentConsumableV2Mutations as fulfillmentConsumableV2MutationsRestored } from './resolvers/fulfillment-consumables-v2-restored'
import { logisticsConsumableV2Queries, logisticsConsumableV2Mutations } from './resolvers/logistics-consumables-v2'
import { sellerInventoryV2Queries } from './resolvers/seller-inventory-v2'
import { CommercialDataAudit } from './security/commercial-data-audit'
import { createSecurityContext } from './security/index'
import '@/lib/seed-init' // Автоматическая инициализация БД
// 🔒 HELPER: Создание безопасного контекста с организационными данными
function createSecureContextWithOrgData(context: Context, currentUser: { organization: { id: string; type: string } }) {
@ -1310,38 +1309,35 @@ export const resolvers = {
`📊 FULFILLMENT SUPPLIES RECEIVED TODAY V2 (ПРИБЫЛО): ${fulfillmentSuppliesReceivedTodayV2.length} orders, ${fulfillmentSuppliesChangeToday} items`,
)
// Расходники селлеров - получаем из таблицы Supply (актуальные остатки на складе фулфилмента)
// ИСПРАВЛЕНО: считаем из Supply с типом SELLER_CONSUMABLES
const sellerSuppliesFromWarehouse = await prisma.supply.findMany({
// V2: Расходники селлеров - получаем из SellerConsumableInventory
const sellerInventoryFromWarehouse = await prisma.sellerConsumableInventory.findMany({
where: {
organizationId: organizationId, // Склад фулфилмента
type: 'SELLER_CONSUMABLES', // ТОЛЬКО расходники селлеров
fulfillmentCenterId: organizationId, // Склад фулфилмента
},
})
const sellerSuppliesCount = sellerSuppliesFromWarehouse.reduce(
(sum, supply) => sum + (supply.currentStock || 0),
const sellerSuppliesCount = sellerInventoryFromWarehouse.reduce(
(sum, item) => sum + (item.currentStock || 0),
0,
)
console.warn(`💼 SELLER SUPPLIES DEBUG: totalCount=${sellerSuppliesCount} (from Supply warehouse)`)
console.warn(`💼 SELLER SUPPLIES V2 DEBUG: totalCount=${sellerSuppliesCount} (from SellerConsumableInventory)`)
// Изменения расходников селлеров за сутки - считаем из Supply записей, созданных за сутки
const sellerSuppliesReceivedToday = await prisma.supply.findMany({
// V2: Изменения расходников селлеров за сутки - считаем поступления за сутки
const sellerSuppliesReceivedTodayV2 = await prisma.sellerConsumableInventory.findMany({
where: {
organizationId: organizationId, // Склад фулфилмента
type: 'SELLER_CONSUMABLES', // ТОЛЬКО расходники селлеров
createdAt: { gte: oneDayAgo }, // Созданы за последние сутки
fulfillmentCenterId: organizationId,
lastSupplyDate: { gte: oneDayAgo }, // Пополнены за последние сутки
},
})
const sellerSuppliesChangeToday = sellerSuppliesReceivedToday.reduce(
(sum, supply) => sum + (supply.currentStock || 0),
const sellerSuppliesChangeToday = sellerSuppliesReceivedTodayV2.reduce(
(sum, item) => sum + (item.totalReceived || 0),
0,
)
console.warn(
`📊 SELLER SUPPLIES RECEIVED TODAY: ${sellerSuppliesReceivedToday.length} supplies, ${sellerSuppliesChangeToday} items`,
`📊 SELLER SUPPLIES RECEIVED TODAY V2: ${sellerSuppliesReceivedTodayV2.length} supplies, ${sellerSuppliesChangeToday} items`,
)
// Вычисляем процентные изменения
@ -1551,8 +1547,10 @@ export const resolvers = {
})
},
// Расходники селлеров на складе фулфилмента (новый resolver)
// V2: Расходники селлеров на складе фулфилмента (обновлено на V2 систему)
sellerSuppliesOnWarehouse: async (_: unknown, __: unknown, context: Context) => {
console.warn('🚀 V2 SELLER SUPPLIES ON WAREHOUSE RESOLVER CALLED')
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
@ -1573,60 +1571,115 @@ export const resolvers = {
throw new GraphQLError('Доступ разрешен только для фулфилмент-центров')
}
// ИСПРАВЛЕНО: Усиленная фильтрация расходников селлеров
const sellerSupplies = await prisma.supply.findMany({
where: {
organizationId: currentUser.organization.id, // На складе этого фулфилмента
type: 'SELLER_CONSUMABLES' as const, // Только расходники селлеров
sellerOwnerId: { not: null }, // ОБЯЗАТЕЛЬНО должен быть владелец-селлер
},
include: {
organization: true, // Фулфилмент-центр (хранитель)
sellerOwner: true, // Селлер-владелец расходников
},
orderBy: { createdAt: 'desc' },
})
try {
// V2: Получаем данные из SellerConsumableInventory вместо старой Supply таблицы
const sellerInventory = await prisma.sellerConsumableInventory.findMany({
where: {
fulfillmentCenterId: currentUser.organization.id,
},
include: {
seller: true,
fulfillmentCenter: true,
product: {
include: {
organization: true, // Поставщик товара
},
},
},
orderBy: [
{ seller: { name: 'asc' } }, // Группируем по селлерам
{ updatedAt: 'desc' },
],
})
// Логирование для отладки
console.warn('🔍 ИСПРАВЛЕНО: Запрос расходников селлеров на складе фулфилмента:', {
fulfillmentId: currentUser.organization.id,
fulfillmentName: currentUser.organization.name,
totalSupplies: sellerSupplies.length,
sellerSupplies: sellerSupplies.map((supply) => ({
id: supply.id,
name: supply.name,
type: supply.type,
sellerOwnerId: supply.sellerOwnerId,
sellerOwnerName: supply.sellerOwner?.name || supply.sellerOwner?.fullName,
currentStock: supply.currentStock,
})),
})
console.warn('📊 V2 Seller Inventory loaded for warehouse:', {
fulfillmentId: currentUser.organization.id,
fulfillmentName: currentUser.organization.name,
inventoryCount: sellerInventory.length,
uniqueSellers: new Set(sellerInventory.map(item => item.sellerId)).size,
})
// ДВОЙНАЯ ПРОВЕРКА: Фильтруем на уровне кода для гарантии
const filteredSupplies = sellerSupplies.filter((supply) => {
const isValid =
supply.type === 'SELLER_CONSUMABLES' && supply.sellerOwnerId != null && supply.sellerOwner != null
// Преобразуем V2 данные в формат Supply для совместимости с фронтендом
const suppliesFormatted = sellerInventory.map((item) => {
const status = item.currentStock > 0 ? 'На складе' : 'Недоступен'
const supplier = item.product.organization?.name || 'Неизвестен'
if (!isValid) {
console.warn('⚠️ ОТФИЛЬТРОВАН некорректный расходник:', {
id: supply.id,
name: supply.name,
type: supply.type,
sellerOwnerId: supply.sellerOwnerId,
hasSellerOwner: !!supply.sellerOwner,
})
}
// Дополнительная проверка на null значения
if (!item.seller?.inn) {
console.error('❌ КРИТИЧЕСКАЯ ОШИБКА: seller.inn is null/undefined', {
sellerId: item.sellerId,
sellerName: item.seller?.name,
itemId: item.id,
})
}
return isValid
})
return {
// === ИДЕНТИФИКАЦИЯ ===
id: item.id,
productId: item.product.id,
// === ОСНОВНЫЕ ДАННЫЕ ===
name: item.product.name,
article: item.product.article,
description: item.product.description || '',
unit: item.product.unit || 'шт',
category: item.product.category || 'Расходники',
imageUrl: item.product.imageUrl,
// === СКЛАДСКИЕ ДАННЫЕ ===
currentStock: item.currentStock,
minStock: item.minStock,
usedStock: item.totalUsed || 0,
quantity: item.totalReceived,
reservedStock: item.reservedStock,
// === ЦЕНЫ ===
price: parseFloat(item.averageCost.toString()),
pricePerUnit: item.usagePrice ? parseFloat(item.usagePrice.toString()) : null,
// === СТАТУС И МЕТАДАННЫЕ ===
status,
isAvailable: item.currentStock > 0,
supplier,
type: 'SELLER_CONSUMABLES', // Для совместимости с фронтендом
date: item.lastSupplyDate?.toISOString() || item.createdAt.toISOString(),
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
// === СВЯЗИ ===
organization: {
id: item.fulfillmentCenter.id,
name: item.fulfillmentCenter.name,
fullName: item.fulfillmentCenter.fullName,
type: item.fulfillmentCenter.type,
},
sellerOwner: {
id: item.seller.id,
name: item.seller.name || 'Неизвестно',
fullName: item.seller.fullName || item.seller.name || 'Неизвестно',
inn: item.seller.inn || 'НЕ_УКАЗАН',
type: item.seller.type,
},
sellerOwnerId: item.sellerId, // Для совместимости
// === ДОПОЛНИТЕЛЬНЫЕ ПОЛЯ ===
notes: item.notes,
actualQuantity: item.currentStock,
}
})
console.warn('✅ ФИНАЛЬНЫЙ РЕЗУЛЬТАТ после фильтрации:', {
originalCount: sellerSupplies.length,
filteredCount: filteredSupplies.length,
removedCount: sellerSupplies.length - filteredSupplies.length,
})
console.warn('✅ V2 Seller Supplies formatted for frontend:', {
count: suppliesFormatted.length,
totalStock: suppliesFormatted.reduce((sum, item) => sum + item.currentStock, 0),
lowStockItems: suppliesFormatted.filter(item => item.currentStock <= item.minStock).length,
})
return filteredSupplies
return suppliesFormatted
} catch (error) {
console.error('❌ Error in V2 seller supplies on warehouse resolver:', error)
return []
}
},
// Мои товары и расходники (для поставщиков)
@ -2857,6 +2910,9 @@ export const resolvers = {
// Новая система складских остатков V2 (заменяет старый myFulfillmentSupplies)
...fulfillmentInventoryV2Queries,
// V2 система складских остатков расходников селлера
...sellerInventoryV2Queries,
},
Mutation: {
@ -5513,46 +5569,8 @@ export const resolvers = {
}
}
// Создаем расходники на основе заказанных товаров
// Расходники создаются в организации получателя (фулфилмент-центре)
// Определяем тип расходников на основе consumableType
const supplyType =
args.input.consumableType === 'SELLER_CONSUMABLES' ? 'SELLER_CONSUMABLES' : 'FULFILLMENT_CONSUMABLES'
// Определяем sellerOwnerId для расходников селлеров
const sellerOwnerId = supplyType === 'SELLER_CONSUMABLES' ? currentUser.organization!.id : null
const suppliesData = args.input.items.map((item) => {
const product = products.find((p) => p.id === item.productId)!
const productWithCategory = supplyOrder.items.find(
(orderItem: { productId: string; product: { category?: { name: string } | null } }) =>
orderItem.productId === item.productId,
)?.product
return {
name: product.name,
article: product.article, // ИСПРАВЛЕНО: Добавляем артикул товара для уникальности
description: product.description || `Заказано у ${partner.name}`,
price: product.price, // Цена закупки у поставщика
quantity: item.quantity,
unit: 'шт',
category: productWithCategory?.category?.name || 'Расходники',
status: 'planned', // Статус "запланировано" (ожидает одобрения поставщиком)
date: new Date(args.input.deliveryDate),
supplier: partner.name || partner.fullName || 'Не указан',
minStock: Math.round(item.quantity * 0.1), // 10% от заказанного как минимальный остаток
currentStock: 0, // Пока товар не пришел
type: supplyType, // ИСПРАВЛЕНО: Добавляем тип расходников
sellerOwnerId: sellerOwnerId, // ИСПРАВЛЕНО: Добавляем владельца для расходников селлеров
// Расходники создаются в организации получателя (фулфилмент-центре)
organizationId: fulfillmentCenterId || currentUser.organization!.id,
}
})
// Создаем расходники
await prisma.supply.createMany({
data: suppliesData,
})
// V2 СИСТЕМА: Расходники будут автоматически созданы при подтверждении заказа
console.warn('📦 V2 система: расходники будут созданы автоматически при доставке через соответствующие резолверы')
// 🔔 ОТПРАВЛЯЕМ УВЕДОМЛЕНИЕ ПОСТАВЩИКУ О НОВОМ ЗАКАЗЕ
try {
@ -7299,113 +7317,8 @@ export const resolvers = {
}
}
// Обновляем расходники
for (const item of existingOrder.items) {
console.warn('📦 Обрабатываем товар:', {
productName: item.product.name,
quantity: item.quantity,
targetOrganizationId,
consumableType: existingOrder.consumableType,
})
// ИСПРАВЛЕНИЕ: Определяем правильный тип расходников
const isSellerSupply = existingOrder.consumableType === 'SELLER_CONSUMABLES'
const supplyType = isSellerSupply ? 'SELLER_CONSUMABLES' : 'FULFILLMENT_CONSUMABLES'
const sellerOwnerId = isSellerSupply ? existingOrder.organizationId : null
console.warn('🔍 Определен тип расходников:', {
isSellerSupply,
supplyType,
sellerOwnerId,
})
// ИСПРАВЛЕНИЕ: Ищем по Артикул СФ для уникальности вместо имени
const whereCondition = isSellerSupply
? {
organizationId: targetOrganizationId,
article: item.product.article, // ИЗМЕНЕНО: поиск по article вместо name
type: 'SELLER_CONSUMABLES' as const,
sellerOwnerId: existingOrder.organizationId,
}
: {
organizationId: targetOrganizationId,
article: item.product.article, // ИЗМЕНЕНО: поиск по article вместо name
type: 'FULFILLMENT_CONSUMABLES' as const,
sellerOwnerId: null, // Для фулфилмента sellerOwnerId должен быть null
}
console.warn('🔍 Ищем существующий расходник с условиями:', whereCondition)
const existingSupply = await prisma.supply.findFirst({
where: whereCondition,
})
if (existingSupply) {
console.warn('📈 ОБНОВЛЯЕМ существующий расходник:', {
id: existingSupply.id,
oldStock: existingSupply.currentStock,
oldQuantity: existingSupply.quantity,
addingQuantity: item.quantity,
})
// ОБНОВЛЯЕМ существующий расходник
const updatedSupply = await prisma.supply.update({
where: { id: existingSupply.id },
data: {
currentStock: existingSupply.currentStock + item.quantity,
// ❌ ИСПРАВЛЕНО: НЕ обновляем quantity - это изначальное количество заказа!
// quantity остается как было изначально заказано
status: 'in-stock', // Меняем статус на "на складе"
updatedAt: new Date(),
},
})
console.warn('✅ Расходник ОБНОВЛЕН (НЕ создан дубликат):', {
id: updatedSupply.id,
name: updatedSupply.name,
newCurrentStock: updatedSupply.currentStock,
newTotalQuantity: updatedSupply.quantity,
type: updatedSupply.type,
})
} else {
console.warn(' СОЗДАЕМ новый расходник (не найден существующий):', {
name: item.product.name,
quantity: item.quantity,
organizationId: targetOrganizationId,
type: supplyType,
sellerOwnerId: sellerOwnerId,
})
// СОЗДАЕМ новый расходник
const newSupply = await prisma.supply.create({
data: {
name: item.product.name,
article: item.product.article, // ДОБАВЛЕНО: Артикул СФ для уникальности
description: item.product.description || `Поставка от ${existingOrder.partner.name}`,
price: item.price, // Цена закупки у поставщика
quantity: item.quantity,
unit: 'шт',
category: item.product.category?.name || 'Расходники',
status: 'in-stock',
date: new Date(),
supplier: existingOrder.partner.name || existingOrder.partner.fullName || 'Не указан',
minStock: Math.round(item.quantity * 0.1),
currentStock: item.quantity,
organizationId: targetOrganizationId,
type: supplyType as 'SELLER_CONSUMABLES' | 'FULFILLMENT_CONSUMABLES',
sellerOwnerId: sellerOwnerId,
},
})
console.warn('✅ Новый расходник СОЗДАН:', {
id: newSupply.id,
name: newSupply.name,
currentStock: newSupply.currentStock,
type: newSupply.type,
sellerOwnerId: newSupply.sellerOwnerId,
})
}
}
// V2 СИСТЕМА: Расходники автоматически обрабатываются в seller-consumables.ts и fulfillment-consumables.ts
console.warn('📦 V2 система автоматически обработает инвентарь через специализированные резолверы')
console.warn('🎉 Склад организации успешно обновлен!')
}
@ -8412,54 +8325,8 @@ export const resolvers = {
},
})
// Добавляем расходники в склад фулфилмента как SELLER_CONSUMABLES
console.warn('📦 Обновляем склад фулфилмента для селлерской поставки...')
for (const item of sellerSupply.items) {
const existingSupply = await prisma.supply.findFirst({
where: {
organizationId: currentUser.organization.id,
article: item.product.article,
type: 'SELLER_CONSUMABLES',
sellerOwnerId: sellerSupply.sellerId,
},
})
if (existingSupply) {
await prisma.supply.update({
where: { id: existingSupply.id },
data: {
currentStock: existingSupply.currentStock + item.requestedQuantity,
status: 'in-stock',
},
})
console.warn(
`📈 Обновлен расходник селлера "${item.product.name}" (владелец: ${sellerSupply.seller.name}): ${existingSupply.currentStock} -> ${existingSupply.currentStock + item.requestedQuantity}`,
)
} else {
await prisma.supply.create({
data: {
name: item.product.name,
article: item.product.article,
description: `Расходники селлера ${sellerSupply.seller.name || sellerSupply.seller.fullName}`,
price: item.unitPrice,
quantity: item.requestedQuantity,
actualQuantity: item.requestedQuantity,
currentStock: item.requestedQuantity,
usedStock: 0,
unit: 'шт',
category: item.product.category?.name || 'Расходники',
status: 'in-stock',
supplier: sellerSupply.supplier?.name || sellerSupply.supplier?.fullName || 'Поставщик',
type: 'SELLER_CONSUMABLES',
sellerOwnerId: sellerSupply.sellerId,
organizationId: currentUser.organization.id,
},
})
console.warn(
` Создан новый расходник селлера "${item.product.name}" (владелец: ${sellerSupply.seller.name}): ${item.requestedQuantity} единиц`,
)
}
}
// V2 СИСТЕМА: Инвентарь селлера автоматически обновляется через SellerConsumableInventory
console.warn('📦 V2 система автоматически обновит SellerConsumableInventory через processSellerConsumableSupplyReceipt')
return {
success: true,
@ -8565,81 +8432,8 @@ export const resolvers = {
}
}
// Обновляем склад фулфилмента с учетом типа расходников
console.warn('📦 Обновляем склад фулфилмента...')
console.warn(`🏷️ Тип поставки: ${existingOrder.consumableType || 'FULFILLMENT_CONSUMABLES'}`)
for (const item of existingOrder.items) {
// Определяем тип расходников и владельца
const isSellerSupply = existingOrder.consumableType === 'SELLER_CONSUMABLES'
const supplyType = isSellerSupply ? 'SELLER_CONSUMABLES' : 'FULFILLMENT_CONSUMABLES'
const sellerOwnerId = isSellerSupply ? updatedOrder.organization?.id : null
// Для расходников селлеров ищем по Артикул СФ И по владельцу
const whereCondition = isSellerSupply
? {
organizationId: currentUser.organization.id,
article: item.product.article, // ИЗМЕНЕНО: поиск по article вместо name
type: 'SELLER_CONSUMABLES' as const,
sellerOwnerId: sellerOwnerId,
}
: {
organizationId: currentUser.organization.id,
article: item.product.article, // ИЗМЕНЕНО: поиск по article вместо name
type: 'FULFILLMENT_CONSUMABLES' as const,
}
const existingSupply = await prisma.supply.findFirst({
where: whereCondition,
})
if (existingSupply) {
await prisma.supply.update({
where: { id: existingSupply.id },
data: {
currentStock: existingSupply.currentStock + item.quantity,
// ❌ ИСПРАВЛЕНО: НЕ обновляем quantity - это изначальное количество заказа!
status: 'in-stock',
},
})
console.warn(
`📈 Обновлен существующий ${
isSellerSupply ? 'расходник селлера' : 'расходник фулфилмента'
} "${item.product.name}" ${
isSellerSupply ? `(владелец: ${updatedOrder.organization?.name})` : ''
}: ${existingSupply.currentStock} -> ${existingSupply.currentStock + item.quantity}`,
)
} else {
await prisma.supply.create({
data: {
name: item.product.name,
article: item.product.article, // ДОБАВЛЕНО: Артикул СФ для уникальности
description: isSellerSupply
? `Расходники селлера ${updatedOrder.organization?.name || updatedOrder.organization?.fullName}`
: item.product.description || `Расходники от ${updatedOrder.partner.name}`,
price: item.price, // Цена закупки у поставщика
quantity: item.quantity,
actualQuantity: item.quantity, // НОВОЕ: Фактически поставленное количество
currentStock: item.quantity,
usedStock: 0,
unit: 'шт',
category: item.product.category?.name || 'Расходники',
status: 'in-stock',
supplier: updatedOrder.partner.name || updatedOrder.partner.fullName || 'Поставщик',
type: supplyType as 'SELLER_CONSUMABLES' | 'FULFILLMENT_CONSUMABLES',
sellerOwnerId: sellerOwnerId,
organizationId: currentUser.organization.id,
},
})
console.warn(
` Создан новый ${
isSellerSupply ? 'расходник селлера' : 'расходник фулфилмента'
} "${item.product.name}" ${
isSellerSupply ? `(владелец: ${updatedOrder.organization?.name})` : ''
}: ${item.quantity} единиц`,
)
}
}
// V2 СИСТЕМА: Инвентарь автоматически обновляется через специализированные резолверы
console.warn('📦 V2 система: склад обновится автоматически через FulfillmentConsumableInventory и SellerConsumableInventory')
console.warn('🎉 Синхронизация склада завершена успешно!')