diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d213d54..f04255f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -115,8 +115,9 @@ model Organization { referrerTransactions ReferralTransaction[] @relation("ReferrerTransactions") sellerStatsCaches SellerStatsCache[] @relation("SellerStatsCaches") services Service[] - supplies Supply[] - sellerSupplies Supply[] @relation("SellerSupplies") + // ❌ V1 LEGACY - закомментировано после миграции V1→V2 + // supplies Supply[] + // sellerSupplies Supply[] @relation("SellerSupplies") fulfillmentSupplyOrders SupplyOrder[] @relation("SupplyOrderFulfillmentCenter") logisticsSupplyOrders SupplyOrder[] @relation("SupplyOrderLogistics") supplyOrders SupplyOrder[] @@ -169,6 +170,7 @@ model ApiKey { id String @id @default(cuid()) marketplace MarketplaceType apiKey String + clientId String? // Для Ozon API isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -250,35 +252,45 @@ model Service { @@map("services") } -model Supply { - id String @id @default(cuid()) - name String - article String - description String? - price Decimal @db.Decimal(10, 2) - pricePerUnit Decimal? @db.Decimal(10, 2) - quantity Int @default(0) - unit String @default("шт") - category String @default("Расходники") - status String @default("planned") - date DateTime @default(now()) - supplier String @default("Не указан") - minStock Int @default(0) - currentStock Int @default(0) - usedStock Int @default(0) - imageUrl String? - type SupplyType @default(FULFILLMENT_CONSUMABLES) - sellerOwnerId String? - shopLocation String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - organizationId String - actualQuantity Int? - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - sellerOwner Organization? @relation("SellerSupplies", fields: [sellerOwnerId], references: [id]) - - @@map("supplies") -} +// ❌ V1 LEGACY MODEL - ЗАКОММЕНТИРОВАНО ПОСЛЕ МИГРАЦИИ НА V2 +// Заменено модульной системой: +// - FulfillmentConsumableInventory (расходники ФФ) +// - SellerConsumableInventory (расходники селлера на складе ФФ) +// - SellerGoodsInventory (товары селлера на складе ФФ) +// - FulfillmentConsumable (конфигурация расходников ФФ) +// +// Дата миграции: 2025-09-12 +// Статус: V2 система полностью функциональна +// +// model Supply { +// id String @id @default(cuid()) +// name String +// article String +// description String? +// price Decimal @db.Decimal(10, 2) +// pricePerUnit Decimal? @db.Decimal(10, 2) +// quantity Int @default(0) +// unit String @default("шт") +// category String @default("Расходники") +// status String @default("planned") +// date DateTime @default(now()) +// supplier String @default("Не указан") +// minStock Int @default(0) +// currentStock Int @default(0) +// usedStock Int @default(0) +// imageUrl String? +// type SupplyType @default(FULFILLMENT_CONSUMABLES) +// sellerOwnerId String? +// shopLocation String? +// createdAt DateTime @default(now()) +// updatedAt DateTime @updatedAt +// organizationId String +// actualQuantity Int? +// organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) +// sellerOwner Organization? @relation("SellerSupplies", fields: [sellerOwnerId], references: [id]) +// +// @@map("supplies") +// } model Category { id String @id @default(cuid()) diff --git a/src/graphql/resolvers/domains/inventory.ts b/src/graphql/resolvers/domains/inventory.ts new file mode 100644 index 0000000..67fbde8 --- /dev/null +++ b/src/graphql/resolvers/domains/inventory.ts @@ -0,0 +1,1047 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { notifyOrganization } from '../../../lib/realtime' +import { DomainResolvers } from '../shared/types' +import { + getCurrentUser, + withAuth, + withOrgTypeAuth +} from '../shared/auth-utils' + +// ============================================================================= +// 🔐 ЛОКАЛЬНЫЕ AUTH HELPERS +// ============================================================================= + +const checkFulfillmentAccess = async (userId: string) => { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { organization: true }, + }) + + if (!user?.organizationId) { + throw new GraphQLError('Пользователь не привязан к организации', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + if (!user.organization || user.organization.type !== 'FULFILLMENT') { + throw new GraphQLError('Доступно только для фулфилмент-центров', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + return user +} + +// ============================================================================= +// 📦 INVENTORY DOMAIN RESOLVERS +// ============================================================================= + +export const inventoryResolvers: DomainResolvers = { + Query: { + // Мои поставки расходников (для фулфилмента) - ОПТИМИЗИРОВАНО + myFulfillmentConsumableSupplies: withOrgTypeAuth(['FULFILLMENT'], + async (_: unknown, __: unknown, context: Context, user: any) => { + console.log('🔍 MY_FULFILLMENT_CONSUMABLE_SUPPLIES DOMAIN QUERY STARTED:', { + userId: context.user?.id, + organizationId: user.organizationId + }) + + const supplies = await prisma.fulfillmentConsumableSupplyOrder.findMany({ + where: { + fulfillmentCenterId: user.organizationId, + }, + select: { + id: true, + status: true, + requestedDeliveryDate: true, + totalItems: true, + createdAt: true, + updatedAt: true, + // Оптимизированные select для связанных объектов + fulfillmentCenter: { + select: { id: true, name: true, type: true } + }, + supplier: { + select: { id: true, name: true, type: true } + }, + logisticsPartner: { + select: { id: true, name: true, type: true } + }, + receivedBy: { + select: { id: true, managerName: true } + }, + items: { + select: { + id: true, + quantity: true, + receivedQuantity: true, + product: { + select: { id: true, name: true, article: true, type: true } + } + } + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: 100, // Добавляем пагинацию для производительности + }) + + console.log('✅ MY_FULFILLMENT_CONSUMABLE_SUPPLIES DOMAIN SUCCESS:', { count: supplies.length }) + return supplies + } + ), + + // Детальная информация о поставке расходников - ОПТИМИЗИРОВАНО + fulfillmentConsumableSupply: withAuth(async (_: unknown, args: { id: string }, context: Context) => { + console.log('🔍 FULFILLMENT_CONSUMABLE_SUPPLY DOMAIN QUERY STARTED:', { + userId: context.user?.id, + supplyId: args.id + }) + + // Используем кешированного пользователя + const user = await getCurrentUser(context) + + const supply = await prisma.fulfillmentConsumableSupplyOrder.findUnique({ + where: { id: args.id }, + select: { + id: true, + status: true, + requestedDeliveryDate: true, + totalItems: true, + fulfillmentCenterId: true, + supplierId: true, + createdAt: true, + updatedAt: true, + // Оптимизированные select + fulfillmentCenter: { + select: { id: true, name: true, type: true } + }, + supplier: { + select: { id: true, name: true, type: true } + }, + logisticsPartner: { + select: { id: true, name: true, type: true } + }, + receivedBy: { + select: { id: true, managerName: true } + }, + items: { + select: { + id: true, + quantity: true, + receivedQuantity: true, + product: { + select: { id: true, name: true, article: true, type: true } + } + } + }, + }, + }) + + if (!supply) { + throw new GraphQLError('Поставка не найдена') + } + + // Проверка доступа + if ( + user.organization.type === 'FULFILLMENT' && + supply.fulfillmentCenterId !== user.organizationId + ) { + throw new GraphQLError('Нет доступа к этой поставке') + } + + if ( + user.organization.type === 'WHOLESALE' && + supply.supplierId !== user.organizationId + ) { + throw new GraphQLError('Нет доступа к этой поставке') + } + + console.log('✅ FULFILLMENT_CONSUMABLE_SUPPLY DOMAIN SUCCESS:', { supplyId: supply.id }) + return supply + }), + + // Складские остатки фулфилмента (V2 система инвентаря) + myFulfillmentSupplies: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_FULFILLMENT_SUPPLIES DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + // Получаем складские остатки из новой V2 модели + const inventory = await prisma.fulfillmentConsumableInventory.findMany({ + where: { + fulfillmentCenterId: user.organizationId!, + }, + include: { + fulfillmentCenter: true, + product: { + include: { + organization: true, // Поставщик товара + }, + }, + }, + orderBy: { + updatedAt: 'desc', + }, + }) + + // Преобразуем V2 данные в формат Supply для совместимости с фронтендом + const suppliesFormatted = inventory.map((item) => { + const status = item.currentStock > 0 ? 'На складе' : 'Недоступен' + const supplier = item.product.organization?.name || 'Неизвестен' + + 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, + + // ЦЕНЫ + price: parseFloat(item.averageCost.toString()), + pricePerUnit: item.resalePrice ? parseFloat(item.resalePrice.toString()) : null, + + // СКЛАДСКИЕ ДАННЫЕ + currentStock: item.currentStock, + minStock: item.minStock, + usedStock: item.totalShipped || 0, + quantity: item.totalReceived, + warehouseStock: item.currentStock, + reservedStock: item.reservedStock, + + // ОТГРУЗКИ + shippedQuantity: item.totalShipped, + totalShipped: item.totalShipped, + + // СТАТУС И МЕТАДАННЫЕ + status, + isAvailable: item.currentStock > 0, + supplier, + date: item.lastSupplyDate?.toISOString() || item.createdAt.toISOString(), + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + + // ДОПОЛНИТЕЛЬНЫЕ ПОЛЯ + notes: item.notes, + warehouseConsumableId: item.id, + actualQuantity: item.currentStock, + } + }) + + console.log('✅ MY_FULFILLMENT_SUPPLIES DOMAIN SUCCESS:', { + count: suppliesFormatted.length, + totalStock: suppliesFormatted.reduce((sum, item) => sum + item.currentStock, 0) + }) + return suppliesFormatted + } catch (error) { + console.error('❌ MY_FULFILLMENT_SUPPLIES DOMAIN ERROR:', error) + return [] + } + }), + + // Расходники селлера на складе фулфилмента (для селлера) + mySellerConsumableInventory: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_SELLER_CONSUMABLE_INVENTORY DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization || user.organization.type !== 'SELLER') { + throw new GraphQLError('Доступно только для селлеров') + } + + // Получаем складские остатки расходников селлера из V2 модели + const inventory = await prisma.sellerConsumableInventory.findMany({ + where: { + sellerId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + product: { + include: { + organization: true, + }, + }, + }, + orderBy: { + updatedAt: 'desc', + }, + }) + + // Преобразуем V2 данные в формат Supply для совместимости с фронтендом + const suppliesFormatted = inventory.map((item) => { + const status = item.currentStock > 0 ? 'На складе' : 'Недоступен' + const supplier = item.product.organization?.name || 'Неизвестен' + + 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, + + // ЦЕНЫ + price: parseFloat(item.averageCost.toString()), + pricePerUnit: item.usagePrice ? parseFloat(item.usagePrice.toString()) : null, + + // СКЛАДСКИЕ ДАННЫЕ + currentStock: item.currentStock, + minStock: item.minStock, + usedStock: item.totalUsed || 0, + quantity: item.totalReceived, + warehouseStock: item.currentStock, + reservedStock: item.reservedStock, + + // ИСПОЛЬЗОВАНИЕ + shippedQuantity: item.totalUsed, + totalShipped: item.totalUsed, + + // СТАТУС И МЕТАДАННЫЕ + status, + isAvailable: item.currentStock > 0, + supplier, + date: item.lastSupplyDate?.toISOString() || item.createdAt.toISOString(), + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + + // ДОПОЛНИТЕЛЬНЫЕ ПОЛЯ + notes: item.notes, + warehouseConsumableId: item.id, + fulfillmentCenter: item.fulfillmentCenter.name, + actualQuantity: item.currentStock, + } + }) + + console.log('✅ MY_SELLER_CONSUMABLE_INVENTORY DOMAIN SUCCESS:', { + count: suppliesFormatted.length, + totalStock: suppliesFormatted.reduce((sum, item) => sum + item.currentStock, 0) + }) + return suppliesFormatted + } catch (error) { + console.error('❌ MY_SELLER_CONSUMABLE_INVENTORY DOMAIN ERROR:', error) + return [] + } + }), + + // Расходники всех селлеров на складе фулфилмента (для фулфилмента) + allSellerConsumableInventory: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 ALL_SELLER_CONSUMABLE_INVENTORY DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + // Получаем складские остатки всех селлеров на нашем складе + const inventory = await prisma.sellerConsumableInventory.findMany({ + where: { + fulfillmentCenterId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + product: { + include: { + organization: true, + }, + }, + }, + orderBy: [ + { seller: { name: 'asc' } }, + { updatedAt: 'desc' }, + ], + }) + + // Возвращаем данные сгруппированные по селлерам + const result = inventory.map((item) => { + const status = item.currentStock > 0 ? 'На складе' : 'Недоступен' + const supplier = item.product.organization?.name || 'Неизвестен' + + return { + // ИДЕНТИФИКАЦИЯ + id: item.id, + productId: item.product.id, + sellerId: item.sellerId, + sellerName: item.seller.name, + + // ОСНОВНЫЕ ДАННЫЕ + 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, + date: item.lastSupplyDate?.toISOString() || item.createdAt.toISOString(), + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + notes: item.notes, + warehouseConsumableId: item.id, + actualQuantity: item.currentStock, + } + }) + + console.log('✅ ALL_SELLER_CONSUMABLE_INVENTORY DOMAIN SUCCESS:', { + count: result.length, + uniqueSellers: new Set(inventory.map(item => item.sellerId)).size + }) + return result + } catch (error) { + console.error('❌ ALL_SELLER_CONSUMABLE_INVENTORY DOMAIN ERROR:', error) + return [] + } + }), + + // Заявки на поставки для поставщиков (новая система v2) + mySupplierConsumableSupplies: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_SUPPLIER_CONSUMABLE_SUPPLIES DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization || user.organization.type !== 'WHOLESALE') { + console.log('⚠️ User is not wholesale, returning empty array') + return [] + } + + const supplies = await prisma.fulfillmentConsumableSupplyOrder.findMany({ + where: { + supplierId: user.organizationId!, + }, + include: { + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + items: { + include: { + product: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + console.log('✅ MY_SUPPLIER_CONSUMABLE_SUPPLIES DOMAIN SUCCESS:', { count: supplies.length }) + return supplies + } catch (error) { + console.error('❌ MY_SUPPLIER_CONSUMABLE_SUPPLIES DOMAIN ERROR:', error) + return [] + } + }), + + // Расходники селлеров на складе фулфилмента (V2 система) + sellerSuppliesOnWarehouse: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 SELLER_SUPPLIES_ON_WAREHOUSE DOMAIN QUERY STARTED:', { userId: context.user?.id }) + + try { + const user = await checkFulfillmentAccess(context.user!.id) + + // V2: Получаем данные из SellerConsumableInventory + const sellerInventory = await prisma.sellerConsumableInventory.findMany({ + where: { + fulfillmentCenterId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + product: { + include: { + organization: true, // Поставщик товара + }, + }, + }, + orderBy: [ + { seller: { name: 'asc' } }, // Группируем по селлерам + { updatedAt: 'desc' }, + ], + }) + + console.log('📊 V2 Seller Inventory loaded for warehouse:', { + fulfillmentId: user.organizationId, + fulfillmentName: user.organization?.name, + inventoryCount: sellerInventory.length, + uniqueSellers: new Set(sellerInventory.map(item => item.sellerId)).size, + }) + + // Преобразуем V2 данные в формат Supply для совместимости с фронтендом + const suppliesFormatted = sellerInventory.map((item) => { + const status = item.currentStock > 0 ? 'На складе' : 'Недоступен' + + return { + // ИДЕНТИФИКАЦИЯ + id: item.id, + productId: item.product.id, + sellerId: item.seller.id, + + // ОСНОВНЫЕ ДАННЫЕ + name: item.product.name, + article: item.product.article, + description: item.product.description || '', + unit: item.product.unit || 'шт', + imageUrl: item.product.imageUrl, + + // ЦЕНЫ + price: parseFloat(item.averageCost.toString()), + + // СКЛАДСКИЕ ДАННЫЕ + currentStock: item.currentStock, + quantity: item.totalReceived, + usedQuantity: item.totalShipped || 0, + + // СТАТУС И МЕТАДАННЫЕ + status, + isAvailable: item.currentStock > 0, + sellerName: item.seller.name, + supplierName: item.product.organization?.name || 'Неизвестен', + date: item.lastSupplyDate?.toISOString() || item.createdAt.toISOString(), + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + } + }) + + console.log('✅ SELLER_SUPPLIES_ON_WAREHOUSE DOMAIN SUCCESS:', { + suppliesCount: suppliesFormatted.length, + sellersCount: new Set(sellerInventory.map(item => item.sellerId)).size, + }) + + return suppliesFormatted + } catch (error) { + console.error('❌ SELLER_SUPPLIES_ON_WAREHOUSE DOMAIN ERROR:', error) + return [] + } + }), + + // Данные склада с партнерами (3-уровневая иерархия) + warehouseData: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 WAREHOUSE_DATA DOMAIN QUERY STARTED:', { userId: context.user?.id }) + + try { + const user = await checkFulfillmentAccess(context.user!.id) + + // Получаем партнеров (селлеров) этого фулфилмента + const partnerships = await prisma.counterparty.findMany({ + where: { + fulfillmentId: user.organizationId!, + status: 'APPROVED', + }, + include: { + seller: { + include: { + users: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }) + + // Получаем данные склада по каждому партнеру + const warehouseData = await Promise.all( + partnerships.map(async (partnership) => { + // Получаем расходники этого селлера на складе + const inventory = await prisma.sellerConsumableInventory.findMany({ + where: { + sellerId: partnership.sellerId, + fulfillmentCenterId: user.organizationId!, + }, + include: { + product: { + include: { + organization: true, + }, + }, + }, + }) + + // Подсчитываем статистику + const totalItems = inventory.reduce((sum, item) => sum + item.currentStock, 0) + const totalValue = inventory.reduce((sum, item) => sum + (item.currentStock * parseFloat(item.averageCost.toString())), 0) + const uniqueProducts = inventory.length + + return { + // ПАРТНЕР (СЕЛЛЕР) + partnerId: partnership.seller.id, + partnerName: partnership.seller.name, + partnerInn: partnership.seller.inn, + partnerType: partnership.seller.type, + + // СТАТИСТИКА СКЛАДА + totalItems, + totalValue, + uniqueProducts, + + // ДЕТАЛИ ИНВЕНТАРЯ + inventory: inventory.map(item => ({ + id: item.id, + productId: item.product.id, + name: item.product.name, + article: item.product.article, + currentStock: item.currentStock, + averageCost: parseFloat(item.averageCost.toString()), + supplierName: item.product.organization?.name || 'Неизвестен', + lastSupplyDate: item.lastSupplyDate, + })), + + // МЕТАДАННЫЕ + partnershipDate: partnership.createdAt, + lastUpdate: partnership.updatedAt, + } + }) + ) + + console.log('✅ WAREHOUSE_DATA DOMAIN SUCCESS:', { + partnersCount: warehouseData.length, + totalInventoryItems: warehouseData.reduce((sum, p) => sum + p.totalItems, 0), + }) + + return warehouseData + } catch (error) { + console.error('❌ WAREHOUSE_DATA DOMAIN ERROR:', error) + return [] + } + }), + }, + + Mutation: { + // Создание поставки расходников (фулфилмент → поставщик) + createFulfillmentConsumableSupply: withAuth(async ( + _: unknown, + args: { + input: { + supplierId: string + requestedDeliveryDate: string + items: Array<{ + productId: string + requestedQuantity: number + }> + notes?: string + } + }, + context: Context, + ) => { + console.log('🔍 CREATE_FULFILLMENT_CONSUMABLE_SUPPLY DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + supplierId: args.input.supplierId + }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + // Проверяем что поставщик существует и является WHOLESALE + const supplier = await prisma.organization.findUnique({ + where: { id: args.input.supplierId }, + }) + + if (!supplier || supplier.type !== 'WHOLESALE') { + throw new GraphQLError('Поставщик не найден или не является оптовиком') + } + + // Проверяем что все товары существуют и принадлежат поставщику + const productIds = args.input.items.map(item => item.productId) + const products = await prisma.product.findMany({ + where: { + id: { in: productIds }, + organizationId: supplier.id, + type: 'CONSUMABLE', + }, + }) + + if (products.length !== productIds.length) { + throw new GraphQLError('Некоторые товары не найдены или не принадлежат поставщику') + } + + // Создаем поставку с items + const supplyOrder = await prisma.fulfillmentConsumableSupplyOrder.create({ + data: { + fulfillmentCenterId: user.organizationId!, + supplierId: supplier.id, + requestedDeliveryDate: new Date(args.input.requestedDeliveryDate), + notes: args.input.notes, + items: { + create: args.input.items.map(item => { + const product = products.find(p => p.id === item.productId)! + return { + productId: item.productId, + requestedQuantity: item.requestedQuantity, + unitPrice: product.price, + totalPrice: product.price.mul(item.requestedQuantity), + } + }), + }, + }, + include: { + fulfillmentCenter: true, + supplier: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + // Отправляем уведомление поставщику о новой заявке + await notifyOrganization(supplier.id, { + type: 'supply-order:new', + title: 'Новая заявка на поставку расходников', + message: `Фулфилмент-центр "${user.organization!.name}" создал заявку на поставку расходников`, + data: { + supplyOrderId: supplyOrder.id, + supplyOrderType: 'FULFILLMENT_CONSUMABLES_V2', + fulfillmentCenterName: user.organization!.name, + itemsCount: args.input.items.length, + requestedDeliveryDate: args.input.requestedDeliveryDate, + }, + }) + + const result = { + success: true, + message: 'Поставка расходников создана успешно', + supplyOrder, + } + console.log('✅ CREATE_FULFILLMENT_CONSUMABLE_SUPPLY DOMAIN SUCCESS:', { supplyOrderId: supplyOrder.id }) + return result + } catch (error: any) { + console.error('❌ CREATE_FULFILLMENT_CONSUMABLE_SUPPLY DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка создания поставки', + supplyOrder: null, + } + } + }), + + // Поставщик одобряет поставку расходников + supplierApproveConsumableSupply: withAuth(async ( + _: unknown, + args: { id: string }, + context: Context, + ) => { + console.log('🔍 SUPPLIER_APPROVE_CONSUMABLE_SUPPLY DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + supplyId: args.id + }) + try { + const user = await checkWholesaleAccess(context.user!.id) + + const supply = await prisma.fulfillmentConsumableSupplyOrder.findUnique({ + where: { id: args.id }, + include: { + supplier: true, + fulfillmentCenter: true, + }, + }) + + if (!supply) { + throw new GraphQLError('Поставка не найдена') + } + + if (supply.supplierId !== user.organizationId) { + throw new GraphQLError('Нет доступа к этой поставке') + } + + if (supply.status !== 'PENDING') { + throw new GraphQLError('Поставку можно одобрить только в статусе PENDING') + } + + const updatedSupply = await prisma.fulfillmentConsumableSupplyOrder.update({ + where: { id: args.id }, + data: { + status: 'SUPPLIER_APPROVED', + supplierApprovedAt: new Date(), + }, + include: { + fulfillmentCenter: true, + supplier: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + const result = { + success: true, + message: 'Поставка одобрена успешно', + order: updatedSupply, + } + console.log('✅ SUPPLIER_APPROVE_CONSUMABLE_SUPPLY DOMAIN SUCCESS:', { supplyId: updatedSupply.id }) + return result + } catch (error: any) { + console.error('❌ SUPPLIER_APPROVE_CONSUMABLE_SUPPLY DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка одобрения поставки', + order: null, + } + } + }), + + // Поставщик отклоняет поставку расходников + supplierRejectConsumableSupply: withAuth(async ( + _: unknown, + args: { id: string; reason?: string }, + context: Context, + ) => { + console.log('🔍 SUPPLIER_REJECT_CONSUMABLE_SUPPLY DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + supplyId: args.id, + reason: args.reason + }) + try { + const user = await checkWholesaleAccess(context.user!.id) + + const supply = await prisma.fulfillmentConsumableSupplyOrder.findUnique({ + where: { id: args.id }, + include: { + supplier: true, + fulfillmentCenter: true, + }, + }) + + if (!supply) { + throw new GraphQLError('Поставка не найдена') + } + + if (supply.supplierId !== user.organizationId) { + throw new GraphQLError('Нет доступа к этой поставке') + } + + if (supply.status !== 'PENDING') { + throw new GraphQLError('Поставку можно отклонить только в статусе PENDING') + } + + const updatedSupply = await prisma.fulfillmentConsumableSupplyOrder.update({ + where: { id: args.id }, + data: { + status: 'REJECTED', + supplierNotes: args.reason || 'Поставка отклонена', + }, + include: { + fulfillmentCenter: true, + supplier: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + const result = { + success: true, + message: 'Поставка отклонена', + order: updatedSupply, + } + console.log('✅ SUPPLIER_REJECT_CONSUMABLE_SUPPLY DOMAIN SUCCESS:', { supplyId: updatedSupply.id }) + return result + } catch (error: any) { + console.error('❌ SUPPLIER_REJECT_CONSUMABLE_SUPPLY DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка отклонения поставки', + order: null, + } + } + }), + + // Поставщик отправляет поставку расходников + supplierShipConsumableSupply: withAuth(async ( + _: unknown, + args: { id: string }, + context: Context, + ) => { + console.log('🔍 SUPPLIER_SHIP_CONSUMABLE_SUPPLY DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + supplyId: args.id + }) + try { + const user = await checkWholesaleAccess(context.user!.id) + + const supply = await prisma.fulfillmentConsumableSupplyOrder.findUnique({ + where: { id: args.id }, + include: { + supplier: true, + fulfillmentCenter: true, + }, + }) + + if (!supply) { + throw new GraphQLError('Поставка не найдена') + } + + if (supply.supplierId !== user.organizationId) { + throw new GraphQLError('Нет доступа к этой поставке') + } + + if (!['SUPPLIER_APPROVED', 'LOGISTICS_CONFIRMED'].includes(supply.status)) { + throw new GraphQLError('Поставку можно отправить только в статусе SUPPLIER_APPROVED или LOGISTICS_CONFIRMED') + } + + const updatedSupply = await prisma.fulfillmentConsumableSupplyOrder.update({ + where: { id: args.id }, + data: { + status: 'SHIPPED', + shippedAt: new Date(), + }, + include: { + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + const result = { + success: true, + message: 'Поставка отправлена', + order: updatedSupply, + } + console.log('✅ SUPPLIER_SHIP_CONSUMABLE_SUPPLY DOMAIN SUCCESS:', { supplyId: updatedSupply.id }) + return result + } catch (error: any) { + console.error('❌ SUPPLIER_SHIP_CONSUMABLE_SUPPLY DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка отправки поставки', + order: null, + } + } + }), + + // V1 Legacy: Резервирование товара на складе + reserveProductStock: async ( + _: unknown, + args: { + productId: string + quantity: number + reason?: string + }, + context: Context, + ) => { + console.warn('🔒 RESERVE_PRODUCT_STOCK (V1) - LEGACY RESOLVER:', { + productId: args.productId, + quantity: args.quantity, + reason: args.reason, + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + // TODO: Реализовать V1 логику резервирования товара на складе + // Может потребоваться миграция на V2 систему управления запасами + return { + success: false, + message: 'V1 Legacy - требуется реализация', + reservationId: null, + } + }, + + // V1 Legacy: Освобождение резерва товара + releaseProductReserve: async ( + _: unknown, + args: { + reservationId: string + reason?: string + }, + context: Context, + ) => { + console.warn('🔓 RELEASE_PRODUCT_RESERVE (V1) - LEGACY RESOLVER:', { + reservationId: args.reservationId, + reason: args.reason, + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + // TODO: Реализовать V1 логику освобождения резерва товара + // Может потребоваться миграция на V2 систему управления резервированием + return { + success: false, + message: 'V1 Legacy - требуется реализация', + } + }, + + // V1 Legacy: Обновить статус товара в пути + updateProductInTransit: async ( + _: unknown, + args: { + productId: string + transitData: { + status: string + location?: string + estimatedArrival?: string + trackingNumber?: string + } + }, + context: Context, + ) => { + console.warn('🚛 UPDATE_PRODUCT_IN_TRANSIT (V1) - LEGACY RESOLVER:', { + productId: args.productId, + transitStatus: args.transitData.status, + location: args.transitData.location, + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + // TODO: Реализовать V1 логику отслеживания товаров в пути + // Может потребоваться интеграция с V2 системой логистики + return { + success: false, + message: 'V1 Legacy - требуется реализация', + product: null, + } + }, + }, +} + +console.warn('🔥 INVENTORY DOMAIN МОДУЛЬ ЭКСПОРТЫ ГОТОВЫ') \ No newline at end of file diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index d95dee0..e9f5eff 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -34,17 +34,16 @@ export const typeDefs = gql` # Услуги организации myServices: [Service!]! - # Расходники селлеров (материалы клиентов) - mySupplies: [Supply!]! + # ✅ V2: Расходники селлеров (материалы клиентов) - теперь из SellerConsumableInventory + mySupplies: [SupplyCompatible!]! - # Доступные расходники для рецептур селлеров (только с ценой и в наличии) - getAvailableSuppliesForRecipe: [SupplyForRecipe!]! + # УДАЛЕН: getAvailableSuppliesForRecipe - неиспользуемый стаб резолвер V1 - # Расходники фулфилмента (материалы для работы фулфилмента) - myFulfillmentSupplies: [Supply!]! + # ✅ V2: Расходники фулфилмента (материалы для работы фулфилмента) - теперь из FulfillmentConsumableInventory + myFulfillmentSupplies: [SupplyCompatible!]! - # Расходники селлеров на складе фулфилмента (только для фулфилмента) - sellerSuppliesOnWarehouse: [Supply!]! + # ✅ V2: Расходники селлеров на складе фулфилмента (только для фулфилмента) - теперь из SellerConsumableInventory + sellerSuppliesOnWarehouse: [SupplyCompatible!]! # Заказы поставок расходников supplyOrders: [SupplyOrder!]! @@ -107,8 +106,8 @@ export const typeDefs = gql` # Публичные услуги контрагента (для фулфилмента) counterpartyServices(organizationId: ID!): [Service!]! - # Публичные расходники контрагента (для поставщиков) - counterpartySupplies(organizationId: ID!): [Supply!]! + # ✅ V2: Публичные расходники контрагента (для поставщиков) - теперь из SellerConsumableInventory + counterpartySupplies(organizationId: ID!): [SupplyCompatible!]! # Админ запросы adminMe: Admin @@ -180,8 +179,8 @@ export const typeDefs = gql` logout: Boolean! # Работа с контрагентами - sendCounterpartyRequest(organizationId: ID!, message: String): CounterpartyRequestResponse! - respondToCounterpartyRequest(requestId: ID!, accept: Boolean!): CounterpartyRequestResponse! + sendCounterpartyRequest(input: SendCounterpartyRequestInput!): CounterpartyRequestResponse! + respondToCounterpartyRequest(input: RespondToCounterpartyRequestInput!): CounterpartyRequestResponse! cancelCounterpartyRequest(requestId: ID!): Boolean! removeCounterparty(organizationId: ID!): Boolean! @@ -224,6 +223,8 @@ export const typeDefs = gql` createSupplyOrder(input: SupplyOrderInput!): SupplyOrderResponse! updateSupplyOrderStatus(id: ID!, status: SupplyOrderStatus!): SupplyOrderResponse! updateSupplyParameters(id: ID!, volume: Float, packagesCount: Int): SupplyOrderResponse! + deleteSupplyOrder(id: ID!): MutationResponse! + bulkUpdateSupplyOrders(ids: [ID!]!, status: SupplyOrderStatus!, notes: String): BulkUpdateResponse! # Назначение логистики фулфилментом assignLogisticsToSupply(supplyOrderId: ID!, logisticsPartnerId: ID!, responsibleId: ID): SupplyOrderResponse! @@ -362,7 +363,6 @@ export const typeDefs = gql` users: [User!]! apiKeys: [ApiKey!]! services: [Service!]! - supplies: [Supply!]! isCounterparty: Boolean isCurrentUser: Boolean hasOutgoingRequest: Boolean @@ -446,6 +446,17 @@ export const typeDefs = gql` user: User } + type MutationResponse { + success: Boolean! + message: String! + } + + type BulkUpdateResponse { + success: Boolean! + message: String! + updatedCount: Int! + } + type InnValidationResponse { success: Boolean! message: String! @@ -500,6 +511,19 @@ export const typeDefs = gql` CANCELLED } + # Input типы для контрагентов + input SendCounterpartyRequestInput { + receiverId: ID! + message: String + requestType: String + } + + input RespondToCounterpartyRequestInput { + requestId: ID! + action: String! # "APPROVE" or "REJECT" + responseMessage: String + } + # Типы для контрагентов type CounterpartyRequest { id: ID! @@ -634,47 +658,81 @@ export const typeDefs = gql` SELLER_CONSUMABLES # Расходники селлеров (принятые от селлеров для хранения) } - type Supply { - id: ID! - productId: ID # ID продукта для фильтрации истории поставок - name: String! - article: String! # ДОБАВЛЕНО: Артикул СФ для уникальности - description: String - # Новые поля для Services архитектуры - pricePerUnit: Float # Цена за единицу для рецептур (может быть null) - unit: String! # Единица измерения: "шт", "кг", "м" - warehouseStock: Int! # Остаток на складе (readonly) - isAvailable: Boolean! # Есть ли на складе (влияет на цвет) - warehouseConsumableId: ID! # Связь со складом - # Поля из базы данных для обратной совместимости - price: Float! # Цена закупки у поставщика (не меняется) - quantity: Int! # Из Prisma schema (заказанное количество) - actualQuantity: Int # НОВОЕ: Фактически поставленное количество (NULL = еще не пересчитали) - category: String! # Из Prisma schema - status: String! # Из Prisma schema - date: DateTime! # Из Prisma schema - supplier: String! # Из Prisma schema - minStock: Int! # Из Prisma schema - currentStock: Int! # Из Prisma schema - usedStock: Int! # Из Prisma schema - type: String! # Из Prisma schema (SupplyType enum) - sellerOwnerId: ID # Из Prisma schema - sellerOwner: Organization # Из Prisma schema - shopLocation: String # Из Prisma schema - imageUrl: String - createdAt: DateTime! - updatedAt: DateTime! - organization: Organization! - } + # ❌ V1 LEGACY TYPE - ЗАКОММЕНТИРОВАНО ПОСЛЕ МИГРАЦИИ НА V2 + # Заменено V2 системой инвентаря с модульными типами + # Дата миграции: 2025-09-12 + # + # type Supply { + # id: ID! + # productId: ID # ID продукта для фильтрации истории поставок + # name: String! + # article: String! # ДОБАВЛЕНО: Артикул СФ для уникальности + # description: String + # # Новые поля для Services архитектуры + # pricePerUnit: Float # Цена за единицу для рецептур (может быть null) + # unit: String! # Единица измерения: "шт", "кг", "м" + # warehouseStock: Int! # Остаток на складе (readonly) + # isAvailable: Boolean! # Есть ли на складе (влияет на цвет) + # warehouseConsumableId: ID! # Связь со складом + # # Поля из базы данных для обратной совместимости + # price: Float! # Цена закупки у поставщика (не меняется) + # quantity: Int! # Из Prisma schema (заказанное количество) + # actualQuantity: Int # НОВОЕ: Фактически поставленное количество (NULL = еще не пересчитали) + # category: String! # Из Prisma schema + # status: String! # Из Prisma schema + # date: DateTime! # Из Prisma schema + # supplier: String! # Из Prisma schema + # minStock: Int! # Из Prisma schema + # currentStock: Int! # Из Prisma schema + # usedStock: Int! # Из Prisma schema + # type: String! # Из Prisma schema (SupplyType enum) + # sellerOwnerId: ID # Из Prisma schema + # sellerOwner: Organization # Из Prisma schema + # shopLocation: String # Из Prisma schema + # imageUrl: String + # createdAt: DateTime! + # updatedAt: DateTime! + # organization: Organization! + # } - # Для рецептур селлеров - только доступные с ценой - type SupplyForRecipe { + # УДАЛЕН: SupplyForRecipe - тип для неиспользуемого getAvailableSuppliesForRecipe + + # ✅ V2 СОВМЕСТИМЫЙ ТИП: Для возврата данных в старом формате + type SupplyCompatible { id: ID! name: String! - pricePerUnit: Float! # Всегда не null - unit: String! + description: String + unit: String + pricePerUnit: Float! + quantity: Int! + currentStock: Int! + totalReceived: Int! + totalUsed: Int! + lastSupplyDate: String + createdAt: String! + updatedAt: String! + sellerId: ID! + fulfillmentCenterId: ID! + productId: ID! + seller: Organization! + fulfillmentCenter: Organization! + product: Product! + reservedStock: Int + minStock: Int + notes: String + # ✅ V2: Дополнительные поля для обратной совместимости + article: String + price: Float! + category: String! + status: String! + date: String! + supplier: String! + usedStock: Int! imageUrl: String - warehouseStock: Int! # Всегда > 0 + type: String! + shopLocation: String + organization: Organization! + sellerOwner: Organization } # Для обновления цены расходника в разделе Услуги @@ -721,7 +779,7 @@ export const typeDefs = gql` type SupplyResponse { success: Boolean! message: String! - supply: Supply + supply: SupplyCompatible # ✅ V2: Исправлено на SupplyCompatible } # Типы для заказов поставок расходников @@ -840,11 +898,11 @@ export const typeDefs = gql` status: String! # Текущий статус заказа } - # Типы для рецептуры продуктов + # ✅ V2: Типы для рецептуры продуктов type ProductRecipe { services: [Service!]! - fulfillmentConsumables: [Supply!]! - sellerConsumables: [Supply!]! + fulfillmentConsumables: [SupplyCompatible!]! + sellerConsumables: [SupplyCompatible!]! marketplaceCardId: String } @@ -1833,24 +1891,11 @@ export const typeDefs = gql` percentChange: Float! } - # Типы для движений товаров (прибыло/убыло) - type SupplyMovements { - arrived: MovementStats! - departed: MovementStats! - } - - type MovementStats { - products: Int! - goods: Int! - defects: Int! - pvzReturns: Int! - fulfillmentSupplies: Int! - sellerSupplies: Int! - } + # УДАЛЕНЫ: SupplyMovements и MovementStats - типы для неиспользуемого supplyMovements extend type Query { fulfillmentWarehouseStats: FulfillmentWarehouseStats! - supplyMovements(period: String): SupplyMovements! + # УДАЛЕН: supplyMovements - неиспользуемый стаб резолвер V1 } # Типы для реферальной системы @@ -2599,11 +2644,11 @@ export const typeDefs = gql` # Расширяем Query для складских остатков селлера extend type Query { - # Мои расходники на складе фулфилмента (для селлера) - mySellerConsumableInventory: [Supply!]! # Возвращаем в формате Supply для совместимости + # ✅ V2: Мои расходники на складе фулфилмента (для селлера) + mySellerConsumableInventory: [SupplyCompatible!]! # Возвращаем в формате SupplyCompatible для совместимости # Все расходники селлеров на складе (для фулфилмента) - allSellerConsumableInventory: [Supply!]! # Для таблицы "Детализация по магазинам" + allSellerConsumableInventory: [SupplyCompatible!]! # ✅ V2: Для таблицы "Детализация по магазинам" } # === V2 RESPONSE ТИПЫ ДЛЯ УСЛУГ ФУЛФИЛМЕНТА ===