diff --git a/src/graphql/resolvers/domains/admin-tools.ts b/src/graphql/resolvers/domains/admin-tools.ts new file mode 100644 index 0000000..979e7e1 --- /dev/null +++ b/src/graphql/resolvers/domains/admin-tools.ts @@ -0,0 +1,290 @@ +import { GraphQLError } from 'graphql' +import bcrypt from 'bcryptjs' +import jwt from 'jsonwebtoken' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' + +// Admin Tools Domain Resolvers - управление административными инструментами + +// ============================================================================= +// 🔧 JWT UTILITIES +// ============================================================================= + +interface AuthTokenPayload { + adminId?: string + username: string + type: 'admin' +} + +// JWT утилита для админов +const generateToken = (payload: AuthTokenPayload): string => { + return jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: '30d' }) +} + +// ============================================================================= +// 🔐 AUTHENTICATION HELPERS +// ============================================================================= + +const withAuth = (resolver: any) => { + return async (parent: any, args: any, context: Context) => { + console.log('🔐 ADMIN TOOLS DOMAIN AUTH CHECK:', { + hasUser: !!context.user, + userId: context.user?.id, + hasAdmin: !!context.admin, + adminId: context.admin?.id, + }) + if (!context.user) { + console.error('❌ AUTH FAILED: No user in context') + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + console.log('✅ AUTH PASSED: Calling resolver') + try { + const result = await resolver(parent, args, context) + console.log('🎯 RESOLVER RESULT TYPE:', typeof result, result === null ? 'NULL RESULT!' : 'Has result') + return result + } catch (error) { + console.error('💥 RESOLVER ERROR:', error) + throw error + } + } +} + +const withAdminAuth = (resolver: any) => { + return async (parent: any, args: any, context: Context) => { + console.log('🔐 ADMIN TOOLS DOMAIN ADMIN AUTH CHECK:', { + hasAdmin: !!context.admin, + adminId: context.admin?.id, + }) + if (!context.admin) { + console.error('❌ ADMIN AUTH FAILED: No admin in context') + throw new GraphQLError('Требуется авторизация администратора', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + console.log('✅ ADMIN AUTH PASSED: Calling resolver') + try { + const result = await resolver(parent, args, context) + console.log('🎯 ADMIN RESOLVER RESULT TYPE:', typeof result, result === null ? 'NULL RESULT!' : 'Has result') + return result + } catch (error) { + console.error('💥 ADMIN RESOLVER ERROR:', error) + throw error + } + } +} + +// ============================================================================= +// 🛠️ ADMIN TOOLS DOMAIN RESOLVERS +// ============================================================================= + +export const adminToolsResolvers: DomainResolvers = { + Query: { + // Получение информации об администраторе + adminMe: withAdminAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 ADMIN_ME DOMAIN QUERY STARTED:', { adminId: context.admin?.id }) + + try { + const admin = await prisma.admin.findUnique({ + where: { id: context.admin!.id }, + }) + + if (!admin) { + throw new GraphQLError('Администратор не найден') + } + + console.log('✅ ADMIN_ME DOMAIN SUCCESS:', { + adminId: admin.id, + username: admin.username, + isActive: admin.isActive, + }) + + return admin + } catch (error) { + console.error('❌ ADMIN_ME DOMAIN ERROR:', error) + throw error + } + }), + + // Получение всех пользователей (админская функция) + allUsers: withAdminAuth(async (_: unknown, args: { search?: string; limit?: number; offset?: number }, context: Context) => { + console.log('🔍 ALL_USERS DOMAIN QUERY STARTED:', { + adminId: context.admin?.id, + search: args.search, + limit: args.limit, + offset: args.offset, + }) + + try { + const limit = args.limit || 50 + const offset = args.offset || 0 + + // Строим условие поиска + const whereCondition = args.search + ? { + OR: [ + { phone: { contains: args.search, mode: 'insensitive' as const } }, + { + organization: { + OR: [ + { name: { contains: args.search, mode: 'insensitive' as const } }, + { fullName: { contains: args.search, mode: 'insensitive' as const } }, + { inn: { contains: args.search, mode: 'insensitive' as const } }, + ], + }, + }, + ], + } + : {} + + // Получаем пользователей с пагинацией + const [users, totalCount] = await Promise.all([ + prisma.user.findMany({ + where: whereCondition, + include: { + organization: { + include: { + apiKeys: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + }), + prisma.user.count({ where: whereCondition }), + ]) + + console.log('✅ ALL_USERS DOMAIN SUCCESS:', { + adminId: context.admin?.id, + usersFound: users.length, + totalCount, + hasSearch: !!args.search, + }) + + return { + users, + total: totalCount, + hasMore: offset + users.length < totalCount, + } + } catch (error) { + console.error('❌ ALL_USERS DOMAIN ERROR:', error) + throw error + } + }), + }, + + Mutation: { + // Авторизация администратора + adminLogin: async ( + _: unknown, + args: { username: string; password: string }, + ) => { + console.log('🔍 ADMIN_LOGIN DOMAIN MUTATION STARTED:', { + username: args.username, + hasPassword: !!args.password, + }) + + try { + // Найти администратора + const admin = await prisma.admin.findUnique({ + where: { username: args.username }, + }) + + if (!admin) { + console.log('❌ ADMIN_LOGIN: Admin not found') + return { + success: false, + message: 'Неверные учетные данные', + token: null, + admin: null, + } + } + + // Проверка активности + if (!admin.isActive) { + console.log('❌ ADMIN_LOGIN: Admin is inactive') + return { + success: false, + message: 'Аккаунт администратора заблокирован', + token: null, + admin: null, + } + } + + // Проверка пароля + const isPasswordValid = await bcrypt.compare(args.password, admin.password) + if (!isPasswordValid) { + console.log('❌ ADMIN_LOGIN: Invalid password') + return { + success: false, + message: 'Неверные учетные данные', + token: null, + admin: null, + } + } + + // Обновление последнего входа + await prisma.admin.update({ + where: { id: admin.id }, + data: { lastLogin: new Date() }, + }) + + // Генерация токена + const token = generateToken({ + adminId: admin.id, + username: admin.username, + type: 'admin', + }) + + console.log('✅ ADMIN_LOGIN DOMAIN SUCCESS:', { + adminId: admin.id, + username: admin.username, + tokenGenerated: !!token, + }) + + return { + success: true, + message: 'Успешная авторизация', + token, + admin: { + ...admin, + password: undefined, // Не возвращаем пароль + }, + } + } catch (error: any) { + console.error('❌ ADMIN_LOGIN DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка при авторизации администратора', + token: null, + admin: null, + } + } + }, + + // Выход администратора из системы + adminLogout: withAdminAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 ADMIN_LOGOUT DOMAIN MUTATION STARTED:', { adminId: context.admin?.id }) + + try { + // В текущей реализации просто возвращаем true + // В реальной системе здесь можно добавить инвалидацию токена + + console.log('✅ ADMIN_LOGOUT DOMAIN SUCCESS:', { + adminId: context.admin?.id, + }) + + return true + } catch (error) { + console.error('❌ ADMIN_LOGOUT DOMAIN ERROR:', error) + throw new GraphQLError('Ошибка при выходе из системы') + } + }), + }, +} + +console.warn('🔥 ADMIN TOOLS DOMAIN МОДУЛЬ ЭКСПОРТЫ ГОТОВЫ') \ No newline at end of file diff --git a/src/graphql/resolvers/domains/analytics.ts b/src/graphql/resolvers/domains/analytics.ts new file mode 100644 index 0000000..5ffe6d9 --- /dev/null +++ b/src/graphql/resolvers/domains/analytics.ts @@ -0,0 +1,376 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' + +// Analytics Domain Resolvers - управление аналитикой и статистикой + +// ============================================================================= +// 🔐 AUTHENTICATION HELPERS +// ============================================================================= + +const withAuth = (resolver: any) => { + return async (parent: any, args: any, context: Context) => { + console.log('🔐 ANALYTICS DOMAIN AUTH CHECK:', { + hasUser: !!context.user, + userId: context.user?.id, + organizationId: context.user?.organizationId, + }) + if (!context.user) { + console.error('❌ AUTH FAILED: No user in context') + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + console.log('✅ AUTH PASSED: Calling resolver') + try { + const result = await resolver(parent, args, context) + console.log('🎯 RESOLVER RESULT TYPE:', typeof result, result === null ? 'NULL RESULT!' : 'Has result') + return result + } catch (error) { + console.error('💥 RESOLVER ERROR:', error) + throw error + } + } +} + +// ============================================================================= +// 📊 ANALYTICS DOMAIN RESOLVERS +// ============================================================================= + +export const analyticsResolvers: DomainResolvers = { + Query: { + // Статистика склада фулфилмента с изменениями за сутки (V2 - только модульные модели) + fulfillmentWarehouseStats: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 FULFILLMENT_WAREHOUSE_STATS V2 DOMAIN QUERY STARTED:', { userId: context.user?.id }) + + try { + const currentUser = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + if (currentUser.organization.type !== 'FULFILLMENT') { + throw new GraphQLError('Доступ разрешен только для фулфилмент-центров') + } + + const organizationId = currentUser.organization.id + + // Временные периоды для анализа изменений + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + yesterday.setHours(0, 0, 0, 0) + + const today = new Date() + today.setHours(0, 0, 0, 0) + + console.log('📊 Calculating V2 warehouse stats:', { + organizationId, + yesterday: yesterday.toISOString(), + today: today.toISOString(), + }) + + // ============================================================================= + // 📦 V2 МОДЕЛЬ: FulfillmentConsumableInventory - расходники на складе ФФ + // ============================================================================= + + const [consumableInventory, consumableLowStock, yesterdayConsumableStock] = await Promise.all([ + // Текущий остаток расходников + prisma.fulfillmentConsumableInventory.aggregate({ + where: { fulfillmentCenterId: organizationId }, + _sum: { currentStock: true }, + _count: true, + }), + + // Расходники с низким остатком + prisma.fulfillmentConsumableInventory.count({ + where: { + fulfillmentCenterId: organizationId, + OR: [ + { currentStock: { lte: 10 } }, // Критический остаток + // TODO: Добавить поле minStock в схему для точного расчета + ], + }, + }), + + // Вчерашний остаток расходников (для расчета изменений) + prisma.fulfillmentConsumableInventory.aggregate({ + where: { + fulfillmentCenterId: organizationId, + updatedAt: { gte: yesterday, lt: today }, + }, + _sum: { currentStock: true }, + }), + ]) + + // ============================================================================= + // 📋 V2 МОДЕЛЬ: FulfillmentConsumableSupplyOrder - заказы расходников + // ============================================================================= + + const [recentSupplyOrders, pendingSupplyOrders, todaySupplyOrders] = await Promise.all([ + // Недавние заказы расходников (за сутки) + prisma.fulfillmentConsumableSupplyOrder.count({ + where: { + fulfillmentCenterId: organizationId, + createdAt: { gte: yesterday }, + }, + }), + + // Ожидающие заказы расходников + prisma.fulfillmentConsumableSupplyOrder.count({ + where: { + fulfillmentCenterId: organizationId, + status: 'PENDING', + }, + }), + + // Заказы расходников за сегодня + prisma.fulfillmentConsumableSupplyOrder.count({ + where: { + fulfillmentCenterId: organizationId, + createdAt: { gte: today }, + }, + }), + ]) + + // ============================================================================= + // 📦 V2 МОДЕЛЬ: SellerGoodsInventory - товары селлеров на складе ФФ + // ============================================================================= + + const [sellerGoodsInventory] = await Promise.all([ + // Товары селлеров на складе ФФ + prisma.sellerGoodsInventory.aggregate({ + where: { + fulfillmentCenterId: organizationId, // товары хранятся у этого ФФ + }, + _sum: { currentStock: true }, + _count: true, + }), + ]) + + // ============================================================================= + // 🧮 РАСЧЕТ ИЗМЕНЕНИЙ ЗА СУТКИ + // ============================================================================= + + const consumableStockChange = (consumableInventory._sum.currentStock || 0) - (yesterdayConsumableStock._sum.currentStock || 0) + const supplyOrdersChange = todaySupplyOrders + + // ============================================================================= + // 📊 ФОРМИРОВАНИЕ СТАТИСТИКИ ПО СПЕЦИФИКАЦИИ GraphQL + // ============================================================================= + + const warehouseStats = { + // Товары (Products) - расходники на складе ФФ + products: { + current: consumableInventory._sum.currentStock || 0, + change: consumableStockChange, + percentChange: (yesterdayConsumableStock._sum.currentStock || 0) > 0 + ? (consumableStockChange / (yesterdayConsumableStock._sum.currentStock || 1)) * 100 + : 0, + }, + + // Товары селлеров (Goods) - готовая продукция на складе ФФ + goods: { + current: sellerGoodsInventory._sum.currentStock || 0, + change: 0, // TODO: добавить расчет изменений товаров за сутки + percentChange: 0, + }, + + // Дефекты (пока заглушка - в V2 системе пока нет отдельной модели дефектов) + defects: { + current: 0, + change: 0, + percentChange: 0, + }, + + // Возвраты с ПВЗ (пока заглушка - в V2 системе пока нет модели возвратов) + pvzReturns: { + current: 0, + change: 0, + percentChange: 0, + }, + + // Поставки расходников фулфилменту + fulfillmentSupplies: { + current: recentSupplyOrders, + change: supplyOrdersChange, + percentChange: recentSupplyOrders > 0 ? (supplyOrdersChange / recentSupplyOrders) * 100 : 0, + }, + + // Расходники селлеров (низкий остаток = требует внимания) + sellerSupplies: { + current: consumableLowStock, + change: 0, // TODO: добавить расчет изменения критических остатков + percentChange: 0, + }, + } + + console.log('✅ FULFILLMENT_WAREHOUSE_STATS V2 DOMAIN SUCCESS:', { + consumableStock: consumableInventory._sum.currentStock, + sellerGoodsStock: sellerGoodsInventory._sum.currentStock, + pendingOrders: pendingSupplyOrders, + lowStockItems: consumableLowStock, + migration: 'V2_MODELS_ONLY', + }) + + return warehouseStats + } catch (error) { + console.error('❌ FULFILLMENT_WAREHOUSE_STATS V2 DOMAIN ERROR:', error) + throw error + } + }), + + // УДАЛЕН: myReferralStats перемещен в referrals.ts для устранения конфликта resolver'ов + + // УДАЛЕН: getAvailableSuppliesForRecipe - неиспользуемый стаб резолвер V1 + + // УДАЛЕН: supplyMovements - неиспользуемый стаб резолвер V1 + + // Получение кеша статистики селлера + getSellerStatsCache: withAuth(async ( + _: unknown, + args: { period: string; dateFrom?: string | null; dateTo?: string | null }, + context: Context, + ) => { + console.log('🔍 GET_SELLER_STATS_CACHE DOMAIN QUERY STARTED:', { + userId: context.user?.id, + period: args.period, + dateFrom: args.dateFrom, + dateTo: args.dateTo, + }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Пользователь не привязан к организации') + } + + // TODO: Реализовать получение кеша из отдельной таблицы или Redis + // Пока возвращаем базовую структуру + const cacheKey = `seller_stats_${user.organization.id}_${args.period}_${args.dateFrom || 'null'}_${args.dateTo || 'null'}` + + console.log('🔍 Looking for cache:', { cacheKey }) + + // Заглушка для кеша - в реальной системе здесь будет запрос к кеш-хранилищу + const mockCacheData = { + period: args.period, + dateFrom: args.dateFrom, + dateTo: args.dateTo, + productsData: null, + productsTotalSales: 0, + totalRevenue: 0, + totalOrders: 0, + averageOrderValue: 0, + topProducts: [], + cachedAt: new Date().toISOString(), + organizationId: user.organization.id, + } + + console.log('✅ GET_SELLER_STATS_CACHE DOMAIN SUCCESS:', { + organizationId: user.organization.id, + period: args.period, + hasCachedData: false, // В реальной системе проверить наличие кеша + }) + + return mockCacheData + } catch (error: any) { + console.error('❌ GET_SELLER_STATS_CACHE DOMAIN ERROR:', error) + return { + period: args.period, + dateFrom: args.dateFrom, + dateTo: args.dateTo, + productsData: null, + productsTotalSales: null, + totalRevenue: null, + totalOrders: null, + averageOrderValue: null, + topProducts: null, + cachedAt: null, + organizationId: null, + } + } + }), + }, + + Mutation: { + + // Сохранение кеша статистики селлера + saveSellerStatsCache: withAuth(async ( + _: unknown, + { + input, + }: { + input: { + period: string + dateFrom?: string | null + dateTo?: string | null + productsData?: string | null + productsTotalSales?: number | null + totalRevenue?: number | null + totalOrders?: number | null + averageOrderValue?: number | null + topProducts?: string | null + } + }, + context: Context, + ) => { + console.log('🔍 SAVE_SELLER_STATS_CACHE DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + period: input.period, + dateFrom: input.dateFrom, + dateTo: input.dateTo, + }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Пользователь не привязан к организации') + } + + const cacheKey = `seller_stats_${user.organization.id}_${input.period}_${input.dateFrom || 'null'}_${input.dateTo || 'null'}` + + console.log('💾 Saving cache:', { + cacheKey, + dataSize: JSON.stringify(input).length, + }) + + // TODO: Реализовать сохранение в отдельной таблице кеша или Redis + // await saveToCache(cacheKey, input) + + console.log('✅ SAVE_SELLER_STATS_CACHE DOMAIN SUCCESS:', { + organizationId: user.organization.id, + period: input.period, + cacheKey, + }) + + return { + success: true, + message: 'Кеш статистики селлера сохранен', + cachedAt: new Date().toISOString(), + } + } catch (error: any) { + console.error('❌ SAVE_SELLER_STATS_CACHE DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка при сохранении кеша статистики', + cachedAt: null, + } + } + }), + }, +} + +console.warn('🔥 ANALYTICS DOMAIN МОДУЛЬ ЭКСПОРТЫ ГОТОВЫ') \ No newline at end of file diff --git a/src/graphql/resolvers/domains/cart.ts b/src/graphql/resolvers/domains/cart.ts new file mode 100644 index 0000000..c8fedd5 --- /dev/null +++ b/src/graphql/resolvers/domains/cart.ts @@ -0,0 +1,568 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' + +// Cart Domain Resolvers - изолированная логика корзины и избранного +export const cartResolvers: DomainResolvers = { + Query: { + // Получение корзины текущего пользователя + myCart: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Найти или создать корзину для организации + let cart = await prisma.cart.findUnique({ + where: { organizationId: currentUser.organization.id }, + include: { + items: { + include: { + product: { + include: { + category: true, + organization: { + include: { + users: true, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }, + organization: { + include: { + users: true, + }, + }, + }, + }) + + if (!cart) { + cart = await prisma.cart.create({ + data: { + organizationId: currentUser.organization.id, + items: { + create: [], + }, + }, + include: { + items: { + include: { + product: { + include: { + category: true, + organization: { + include: { + users: true, + }, + }, + }, + }, + }, + }, + organization: { + include: { + users: true, + }, + }, + }, + }) + } + + return cart + }, + + // Получение избранных товаров текущего пользователя + myFavorites: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Получаем избранные товары + const favorites = await prisma.favorites.findMany({ + where: { organizationId: currentUser.organization.id }, + include: { + product: { + include: { + category: true, + organization: { + include: { + users: true, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: 100, // Добавляем пагинацию для производительности + }) + + return favorites.map((fav) => fav.product) + }, + }, + + Mutation: { + // Добавление товара в корзину + addToCart: async (_: unknown, args: { productId: string; quantity: number }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверяем, что товар существует и активен + const product = await prisma.product.findFirst({ + where: { + id: args.productId, + isActive: true, + }, + include: { + organization: true, + }, + }) + + if (!product) { + return { + success: false, + message: 'Товар не найден или неактивен', + } + } + + // Проверяем, что пользователь не пытается добавить свой собственный товар + if (product.organizationId === currentUser.organization.id) { + return { + success: false, + message: 'Нельзя добавлять собственные товары в корзину', + } + } + + // Найти или создать корзину + let cart = await prisma.cart.findUnique({ + where: { organizationId: currentUser.organization.id }, + }) + + if (!cart) { + cart = await prisma.cart.create({ + data: { + organizationId: currentUser.organization.id, + }, + }) + } + + try { + // Проверяем, есть ли уже этот товар в корзине + const existingItem = await prisma.cartItem.findFirst({ + where: { + cartId: cart.id, + productId: args.productId, + }, + }) + + if (existingItem) { + // Обновляем количество + await prisma.cartItem.update({ + where: { + id: existingItem.id, + }, + data: { + quantity: existingItem.quantity + args.quantity, + }, + }) + } else { + // Создаем новый элемент корзины + await prisma.cartItem.create({ + data: { + cartId: cart.id, + productId: args.productId, + quantity: args.quantity, + }, + }) + } + + // Возвращаем обновленную корзину + const updatedCart = await prisma.cart.findUnique({ + where: { id: cart.id }, + include: { + items: { + include: { + product: { + include: { + category: true, + organization: { + include: { + users: true, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }, + organization: { + include: { + users: true, + }, + }, + }, + }) + + return { + success: true, + message: 'Товар добавлен в корзину', + cart: updatedCart, + } + } catch (error) { + console.error('Ошибка при добавлении в корзину:', error) + return { + success: false, + message: 'Ошибка при добавлении товара в корзину', + } + } + }, + + // Удаление товара из корзины + removeFromCart: async (_: unknown, args: { productId: string }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + const cart = await prisma.cart.findUnique({ + where: { organizationId: currentUser.organization.id }, + }) + + if (!cart) { + return { + success: false, + message: 'Корзина не найдена', + } + } + + try { + await prisma.cartItem.delete({ + where: { + cartId_productId: { + cartId: cart.id, + productId: args.productId, + }, + }, + }) + + // Возвращаем обновленную корзину + const updatedCart = await prisma.cart.findUnique({ + where: { id: cart.id }, + include: { + items: { + include: { + product: { + include: { + category: true, + organization: { + include: { + users: true, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }, + organization: { + include: { + users: true, + }, + }, + }, + }) + + return { + success: true, + message: 'Товар удален из корзины', + cart: updatedCart, + } + } catch (error) { + console.error('Ошибка при удалении из корзины:', error) + return { + success: false, + message: 'Товар не найден в корзине', + } + } + }, + + // Очистка корзины + clearCart: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + const cart = await prisma.cart.findUnique({ + where: { organizationId: currentUser.organization.id }, + }) + + if (!cart) { + return false + } + + try { + await prisma.cartItem.deleteMany({ + where: { cartId: cart.id }, + }) + + return true + } catch (error) { + console.error('Ошибка при очистке корзины:', error) + return false + } + }, + + // Добавление товара в избранное + addToFavorites: async (_: unknown, args: { productId: string }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверяем, что товар существует и активен + const product = await prisma.product.findFirst({ + where: { + id: args.productId, + isActive: true, + }, + include: { + organization: true, + }, + }) + + if (!product) { + return { + success: false, + message: 'Товар не найден или неактивен', + } + } + + // Проверяем, что пользователь не пытается добавить свой собственный товар + if (product.organizationId === currentUser.organization.id) { + return { + success: false, + message: 'Нельзя добавлять собственные товары в избранное', + } + } + + try { + // Проверяем, есть ли уже этот товар в избранном + const existing = await prisma.favorites.findFirst({ + where: { + organizationId: currentUser.organization.id, + productId: args.productId, + }, + }) + + if (existing) { + return { + success: false, + message: 'Товар уже в избранном', + } + } + + // Добавляем в избранное + await prisma.favorites.create({ + data: { + organizationId: currentUser.organization.id, + productId: args.productId, + }, + }) + + // Возвращаем обновленный список избранного + const favorites = await prisma.favorites.findMany({ + where: { organizationId: currentUser.organization.id }, + include: { + product: { + include: { + category: true, + organization: { + include: { + users: true, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: 100, // Добавляем пагинацию для производительности + }) + + return { + success: true, + message: 'Товар добавлен в избранное', + favorites: favorites.map((fav) => fav.product), + } + } catch (error) { + console.error('Ошибка при добавлении в избранное:', error) + return { + success: false, + message: 'Ошибка при добавлении товара в избранное', + } + } + }, + + // Удаление товара из избранного + removeFromFavorites: async (_: unknown, args: { productId: string }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + try { + // Удаляем товар из избранного + await prisma.favorites.deleteMany({ + where: { + organizationId: currentUser.organization.id, + productId: args.productId, + }, + }) + + // Возвращаем обновленный список избранного + const favorites = await prisma.favorites.findMany({ + where: { organizationId: currentUser.organization.id }, + include: { + product: { + include: { + category: true, + organization: { + include: { + users: true, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: 100, // Добавляем пагинацию для производительности + }) + + return { + success: true, + message: 'Товар удален из избранного', + favorites: favorites.map((fav) => fav.product), + } + } catch (error) { + console.error('Ошибка при удалении из избранного:', error) + return { + success: false, + message: 'Ошибка при удалении товара из избранного', + } + } + }, + }, + + // Type resolvers для Cart и CartItem + Cart: { + totalPrice: (parent: { items: Array<{ product: { price: number }; quantity: number }> }) => { + return parent.items.reduce((total, item) => { + return total + Number(item.product.price) * item.quantity + }, 0) + }, + totalItems: (parent: { items: Array<{ quantity: number }> }) => { + return parent.items.reduce((total, item) => total + item.quantity, 0) + }, + }, + + CartItem: { + totalPrice: (parent: { product: { price: number }; quantity: number }) => { + return Number(parent.product.price) * parent.quantity + }, + isAvailable: (parent: { product: { quantity: number; isActive: boolean }; quantity: number }) => { + return parent.product.isActive && parent.product.quantity >= parent.quantity + }, + availableQuantity: (parent: { product: { quantity: number } }) => { + return parent.product.quantity + }, + }, +} \ No newline at end of file diff --git a/src/graphql/resolvers/domains/catalog.ts b/src/graphql/resolvers/domains/catalog.ts new file mode 100644 index 0000000..6fee167 --- /dev/null +++ b/src/graphql/resolvers/domains/catalog.ts @@ -0,0 +1,100 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' + +// Catalog Domain Resolvers - изолированная логика каталога и категорий +export const catalogResolvers: DomainResolvers = { + Query: { + // Получение всех категорий + categories: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const categories = await prisma.category.findMany({ + orderBy: { + name: 'asc', + }, + }) + + return categories + }, + }, + + Mutation: { + // Создание категории (только админы) + createCategory: async ( + _: unknown, + args: { name: string; description?: string }, + context: Context, + ) => { + if (!context.admin) { + throw new GraphQLError('Доступ разрешен только администраторам', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + const category = await prisma.category.create({ + data: { + name: args.name, + description: args.description, + }, + }) + + return category + }, + + // Обновление категории (только админы) + updateCategory: async ( + _: unknown, + args: { id: string; name?: string; description?: string }, + context: Context, + ) => { + if (!context.admin) { + throw new GraphQLError('Доступ разрешен только администраторам', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + const category = await prisma.category.update({ + where: { id: args.id }, + data: { + name: args.name, + description: args.description, + }, + }) + + return category + }, + + // Удаление категории (только админы) + deleteCategory: async (_: unknown, args: { id: string }, context: Context) => { + if (!context.admin) { + throw new GraphQLError('Доступ разрешен только администраторам', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + // Проверяем, есть ли товары в этой категории + const productsCount = await prisma.product.count({ + where: { categoryId: args.id }, + }) + + if (productsCount > 0) { + throw new GraphQLError( + `Невозможно удалить категорию. В ней содержится ${productsCount} товар(ов)`, + ) + } + + await prisma.category.delete({ + where: { id: args.id }, + }) + + return true + }, + }, +} \ No newline at end of file diff --git a/src/graphql/resolvers/domains/counterparty-management.ts b/src/graphql/resolvers/domains/counterparty-management.ts new file mode 100644 index 0000000..ab60dfc --- /dev/null +++ b/src/graphql/resolvers/domains/counterparty-management.ts @@ -0,0 +1,652 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' +import { getCurrentUser } from '../shared/auth-utils' + +// Counterparty Management Domain Resolvers - управление партнерами и заявками +export const counterpartyManagementResolvers: DomainResolvers = { + Query: { + // Мои контрагенты (партнеры) + myCounterparties: async (_: unknown, __: unknown, context: Context) => { + const currentUser = await getCurrentUser(context) + + const counterparties = await prisma.counterparty.findMany({ + where: { + organizationId: currentUser.organization.id, + }, + include: { + counterparty: { + include: { + users: true, + apiKeys: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: 100, // Добавляем пагинацию для производительности + }) + + console.warn('🤝 MY_COUNTERPARTIES:', { + userId: currentUser.id, + organizationId: currentUser.organization.id, + organizationType: currentUser.organization.type, + counterpartiesCount: counterparties.length, + }) + + return counterparties.map((cp) => cp.counterparty) + }, + + // Входящие заявки на партнерство + incomingRequests: async (_: unknown, __: unknown, context: Context) => { + const currentUser = await getCurrentUser(context) + + const incomingRequests = await prisma.counterpartyRequest.findMany({ + where: { + receiverId: currentUser.organization.id, + status: 'PENDING', + }, + include: { + sender: { + include: { + users: true, + apiKeys: true, + }, + }, + receiver: { + include: { + users: true, + apiKeys: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: 50, // Добавляем пагинацию для производительности + }) + + console.warn('📥 INCOMING_REQUESTS:', { + userId: currentUser.id, + organizationId: currentUser.organization.id, + requestsCount: incomingRequests.length, + }) + + return incomingRequests + }, + + // Исходящие заявки на партнерство + outgoingRequests: async (_: unknown, __: unknown, context: Context) => { + const currentUser = await getCurrentUser(context) + + const outgoingRequests = await prisma.counterpartyRequest.findMany({ + where: { + senderId: currentUser.organization.id, + status: 'PENDING', + }, + include: { + sender: { + include: { + users: true, + apiKeys: true, + }, + }, + receiver: { + include: { + users: true, + apiKeys: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: 50, // Добавляем пагинацию для производительности + }) + + console.warn('📤 OUTGOING_REQUESTS:', { + userId: currentUser.id, + organizationId: currentUser.organization.id, + requestsCount: outgoingRequests.length, + }) + + return outgoingRequests + }, + + // V1 Legacy: Услуги контрагентов + counterpartyServices: async (_: unknown, __: unknown, context: Context) => { + console.warn('🔗 COUNTERPARTY_SERVICES (V1) - LEGACY RESOLVER') + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + // TODO: Реализовать логику получения услуг от контрагентов + // Это V1 legacy резолвер - может потребоваться миграция на V2 + return [] + }, + + // V1 Legacy: Поставки контрагентов + counterpartySupplies: async (_: unknown, __: unknown, context: Context) => { + console.warn('📦 COUNTERPARTY_SUPPLIES (V1) - LEGACY RESOLVER') + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + // TODO: Реализовать логику получения поставок от контрагентов + // Это V1 legacy резолвер - может потребоваться миграция на V2 + return [] + }, + }, + + Mutation: { + // Отправить заявку на партнерство + sendCounterpartyRequest: async ( + _: unknown, + args: { + input: { + receiverId: string + message?: string + requestType?: string + } + }, + context: Context, + ) => { + console.warn('📩 SEND_COUNTERPARTY_REQUEST - ВЫЗВАН:', { + receiverId: args.input.receiverId, + requestType: args.input.requestType, + hasMessage: !!args.input.message, + timestamp: new Date().toISOString(), + }) + + const currentUser = await getCurrentUser(context) + + // Проверяем, что получатель существует + const receiverOrganization = await prisma.organization.findUnique({ + where: { id: args.input.receiverId }, + }) + + if (!receiverOrganization) { + return { + success: false, + message: 'Организация-получатель не найдена', + } + } + + // Проверяем, что не отправляем заявку самим себе + if (currentUser.organization.id === args.input.receiverId) { + return { + success: false, + message: 'Нельзя отправить заявку самому себе', + } + } + + try { + // Проверяем, нет ли уже активной заявки + const existingRequest = await prisma.counterpartyRequest.findFirst({ + where: { + senderId: currentUser.organization.id, + receiverId: args.input.receiverId, + status: 'PENDING', + }, + }) + + if (existingRequest) { + return { + success: false, + message: 'Заявка уже отправлена и ожидает рассмотрения', + } + } + + // Проверяем, нет ли уже партнерства + const existingPartnership = await prisma.counterparty.findFirst({ + where: { + organizationId: currentUser.organization.id, + counterpartyId: args.input.receiverId, + }, + }) + + if (existingPartnership) { + return { + success: false, + message: 'Партнерство уже установлено', + } + } + + // Создаем заявку + const request = await prisma.counterpartyRequest.create({ + data: { + senderId: currentUser.organization.id, + receiverId: args.input.receiverId, + message: args.input.message, + // TODO: добавить requestType когда будет в модели + // requestType: args.input.requestType || 'PARTNERSHIP', + status: 'PENDING', + }, + include: { + sender: { + include: { + users: true, + apiKeys: true, + }, + }, + receiver: { + include: { + users: true, + apiKeys: true, + }, + }, + }, + }) + + console.warn('✅ ЗАЯВКА НА ПАРТНЕРСТВО ОТПРАВЛЕНА:', { + requestId: request.id, + senderId: currentUser.organization.id, + senderType: currentUser.organization.type, + receiverId: args.input.receiverId, + receiverType: receiverOrganization.type, + // requestType: request.requestType, // TODO: когда поле будет в модели + }) + + return { + success: true, + message: 'Заявка на партнерство отправлена', + request, + } + } catch (error) { + console.error('Error sending counterparty request:', error) + return { + success: false, + message: 'Ошибка при отправке заявки', + } + } + }, + + // Ответить на заявку партнерства (принять или отклонить) + respondToCounterpartyRequest: async ( + _: unknown, + args: { + input: { + requestId: string + action: 'APPROVE' | 'REJECT' + responseMessage?: string + } + }, + context: Context, + ) => { + console.warn('✉️ RESPOND_TO_COUNTERPARTY_REQUEST - ВЫЗВАН:', { + requestId: args.input.requestId, + action: args.input.action, + hasResponseMessage: !!args.input.responseMessage, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + try { + // Находим заявку + const request = await prisma.counterpartyRequest.findFirst({ + where: { + id: args.input.requestId, + receiverId: currentUser.organization.id, // Только получатель может отвечать + status: 'PENDING', + }, + include: { + sender: true, + receiver: true, + }, + }) + + if (!request) { + return { + success: false, + message: 'Заявка не найдена или уже обработана', + } + } + + if (args.input.action === 'APPROVE') { + // Одобряем заявку - создаем партнерство + + // Создаем двустороннее партнерство + await prisma.counterparty.createMany({ + data: [ + { + organizationId: request.senderId, + counterpartyId: request.receiverId, + }, + { + organizationId: request.receiverId, + counterpartyId: request.senderId, + }, + ], + skipDuplicates: true, + }) + + // Обновляем статус заявки + const updatedRequest = await prisma.counterpartyRequest.update({ + where: { id: args.input.requestId }, + data: { + status: 'ACCEPTED', + // TODO: добавить поля когда будут в модели + // responseMessage: args.input.responseMessage, + // respondedAt: new Date(), + }, + include: { + sender: { + include: { + users: true, + apiKeys: true, + }, + }, + receiver: { + include: { + users: true, + apiKeys: true, + }, + }, + }, + }) + + console.warn('🎉 ЗАЯВКА ОДОБРЕНА И ПАРТНЕРСТВО СОЗДАНО:', { + requestId: args.input.requestId, + senderId: request.senderId, + receiverId: request.receiverId, + senderType: request.sender.type, + receiverType: request.receiver.type, + }) + + return { + success: true, + message: 'Заявка одобрена. Партнерство установлено!', + request: updatedRequest, + } + } else { + // Отклоняем заявку + const updatedRequest = await prisma.counterpartyRequest.update({ + where: { id: args.input.requestId }, + data: { + status: 'REJECTED', + // TODO: добавить поля когда будут в модели + // responseMessage: args.input.responseMessage, + // respondedAt: new Date(), + }, + include: { + sender: { + include: { + users: true, + apiKeys: true, + }, + }, + receiver: { + include: { + users: true, + apiKeys: true, + }, + }, + }, + }) + + console.warn('❌ ЗАЯВКА ОТКЛОНЕНА:', { + requestId: args.input.requestId, + senderId: request.senderId, + receiverId: request.receiverId, + responseMessage: args.input.responseMessage, + }) + + return { + success: true, + message: 'Заявка отклонена', + request: updatedRequest, + } + } + } catch (error) { + console.error('Error responding to counterparty request:', error) + return { + success: false, + message: 'Ошибка при обработке заявки', + } + } + }, + + // Отменить отправленную заявку + cancelCounterpartyRequest: async (_: unknown, args: { requestId: string }, context: Context) => { + console.warn('🚫 CANCEL_COUNTERPARTY_REQUEST - ВЫЗВАН:', { + requestId: args.requestId, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + try { + // Находим заявку, которую отправили мы + const request = await prisma.counterpartyRequest.findFirst({ + where: { + id: args.requestId, + senderId: currentUser.organization.id, // Только отправитель может отменить + status: 'PENDING', + }, + }) + + if (!request) { + return { + success: false, + message: 'Заявка не найдена или уже обработана', + } + } + + // Обновляем статус на отмененный + await prisma.counterpartyRequest.update({ + where: { id: args.requestId }, + data: { + status: 'CANCELLED', + // TODO: добавить поле когда будет в модели + // respondedAt: new Date(), + }, + }) + + console.warn('🗑️ ЗАЯВКА ОТМЕНЕНА:', { + requestId: args.requestId, + senderId: currentUser.organization.id, + receiverId: request.receiverId, + }) + + return { + success: true, + message: 'Заявка отменена', + } + } catch (error) { + console.error('Error cancelling counterparty request:', error) + return { + success: false, + message: 'Ошибка при отмене заявки', + } + } + }, + + // Удалить партнера (разорвать партнерство) + removeCounterparty: async (_: unknown, args: { organizationId: string }, context: Context) => { + console.warn('💔 REMOVE_COUNTERPARTY - ВЫЗВАН:', { + organizationId: args.organizationId, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + try { + // Проверяем, что партнерство существует + const partnership = await prisma.counterparty.findFirst({ + where: { + organizationId: currentUser.organization.id, + counterpartyId: args.organizationId, + }, + }) + + if (!partnership) { + return { + success: false, + message: 'Партнерство не найдено', + } + } + + // Удаляем двустороннее партнерство + await prisma.counterparty.deleteMany({ + where: { + OR: [ + { + organizationId: currentUser.organization.id, + counterpartyId: args.organizationId, + }, + { + organizationId: args.organizationId, + counterpartyId: currentUser.organization.id, + }, + ], + }, + }) + + console.warn('💥 ПАРТНЕРСТВО РАЗОРВАНО:', { + organization1: currentUser.organization.id, + organization2: args.organizationId, + }) + + return { + success: true, + message: 'Партнерство разорвано', + } + } catch (error) { + console.error('Error removing counterparty:', error) + return { + success: false, + message: 'Ошибка при разрыве партнерства', + } + } + }, + + // Автоматическое создание записи для склада (утилита для фулфилмент центров) + autoCreateWarehouseEntry: async (_: unknown, args: { partnerId: string }, context: Context) => { + console.warn('🏭 AUTO_CREATE_WAREHOUSE_ENTRY - ВЫЗВАН:', { + partnerId: args.partnerId, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверяем, что это фулфилмент центр + if (currentUser.organization.type !== 'FULFILLMENT') { + throw new GraphQLError('Функция доступна только для фулфилмент центров') + } + + try { + // Проверяем, что партнер существует и это селлер + const partner = await prisma.organization.findFirst({ + where: { + id: args.partnerId, + type: 'SELLER', + }, + }) + + if (!partner) { + return { + success: false, + message: 'Партнер не найден или не является селлером', + } + } + + // Проверяем, что есть партнерство + const partnership = await prisma.counterparty.findFirst({ + where: { + organizationId: currentUser.organization.id, + counterpartyId: args.partnerId, + }, + }) + + if (!partnership) { + return { + success: false, + message: 'Партнерство с данной организацией не установлено', + } + } + + // TODO: Здесь может быть логика создания складских записей + // Например, создание дефолтных категорий, настройка процессов и т.д. + + console.warn('✅ СКЛАДСКАЯ ЗАПИСЬ СОЗДАНА:', { + fulfillmentId: currentUser.organization.id, + sellerId: args.partnerId, + sellerName: partner.name || partner.fullName, + }) + + return { + success: true, + message: 'Складская запись для партнера создана', + } + } catch (error) { + console.error('Error creating warehouse entry:', error) + return { + success: false, + message: 'Ошибка при создании складской записи', + } + } + }, + }, +} \ No newline at end of file diff --git a/src/graphql/resolvers/domains/employee.ts b/src/graphql/resolvers/domains/employee.ts new file mode 100644 index 0000000..c33883f --- /dev/null +++ b/src/graphql/resolvers/domains/employee.ts @@ -0,0 +1,494 @@ +import type { Prisma } from '@prisma/client' +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' + +// Employee Domain Resolvers - управление сотрудниками (мигрировано из employees-v2.ts) +// ПЕРЕИМЕНОВАННЫЕ РЕЗОЛВЕРЫ: employeesV2 → myEmployees, createEmployeeV2 → createEmployee + +// ============================================================================= +// 🔐 AUTHENTICATION HELPERS +// ============================================================================= + +const withAuth = (resolver: any) => { + return async (parent: any, args: any, context: Context) => { + console.log('🔐 EMPLOYEE DOMAIN AUTH CHECK:', { + hasUser: !!context.user, + userId: context.user?.id, + organizationId: context.user?.organizationId, + }) + if (!context.user) { + console.error('❌ AUTH FAILED: No user in context') + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + console.log('✅ AUTH PASSED: Calling resolver') + try { + const result = await resolver(parent, args, context) + console.log('🎯 RESOLVER RESULT TYPE:', typeof result, result === null ? 'NULL RESULT!' : 'Has result') + return result + } catch (error) { + console.error('💥 RESOLVER ERROR:', error) + throw error + } + } +} + +const checkOrganizationAccess = async (userId: string) => { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { organization: true }, + }) + + if (!user?.organizationId) { + throw new GraphQLError('Пользователь не привязан к организации', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + return user +} + +// ============================================================================= +// 🔄 TRANSFORM HELPERS +// ============================================================================= + +function transformEmployeeToV2(employee: any): any { + return { + id: employee.id, + personalInfo: { + firstName: employee.firstName, + lastName: employee.lastName, + middleName: employee.middleName, + fullName: `${employee.lastName} ${employee.firstName} ${employee.middleName || ''}`.trim(), + birthDate: employee.birthDate, + avatar: employee.avatar, + }, + documentsInfo: { + passportPhoto: employee.passportPhoto, + passportSeries: employee.passportSeries, + passportNumber: employee.passportNumber, + passportIssued: employee.passportIssued, + passportDate: employee.passportDate, + }, + contactInfo: { + phone: employee.phone, + email: employee.email, + telegram: employee.telegram, + whatsapp: employee.whatsapp, + address: employee.address, + emergencyContact: employee.emergencyContact, + emergencyPhone: employee.emergencyPhone, + }, + workInfo: { + position: employee.position, + department: employee.department, + hireDate: employee.hireDate, + salary: employee.salary, + status: employee.status, + }, + organizationId: employee.organizationId, + organization: employee.organization ? { + id: employee.organization.id, + name: employee.organization.name, + fullName: employee.organization.fullName, + type: employee.organization.type, + } : undefined, + createdAt: employee.createdAt, + updatedAt: employee.updatedAt, + scheduleRecords: employee.scheduleRecords?.map(transformScheduleToV2) || [], + } +} + +function transformScheduleToV2(schedule: any): any { + return { + id: schedule.id, + employeeId: schedule.employeeId, + date: schedule.date, + status: schedule.status, + hoursWorked: schedule.hoursWorked, + overtimeHours: schedule.overtimeHours, + notes: schedule.notes, + createdAt: schedule.createdAt, + updatedAt: schedule.updatedAt, + } +} + +// ============================================================================= +// 🧑‍💼 EMPLOYEE DOMAIN RESOLVERS +// ============================================================================= + +export const employeeResolvers: DomainResolvers = { + Query: { + // ПЕРЕИМЕНОВАНО: employeesV2 → myEmployees (для совместимости с монолитом) + myEmployees: withAuth(async (_: unknown, args: any, context: Context) => { + console.log('🔍 MY_EMPLOYEES DOMAIN QUERY STARTED:', { args, userId: context.user?.id }) + try { + const { input = {} } = args + const { + status, + department, + search, + page = 1, + limit = 20, + sortBy = 'CREATED_AT', + sortOrder = 'DESC', + } = input + + const user = await checkOrganizationAccess(context.user!.id) + + // Построение условий фильтрации + const where: Prisma.EmployeeWhereInput = { + organizationId: user.organizationId!, + ...(status?.length && { status: { in: status } }), + ...(department && { department }), + ...(search && { + OR: [ + { firstName: { contains: search, mode: 'insensitive' } }, + { lastName: { contains: search, mode: 'insensitive' } }, + { position: { contains: search, mode: 'insensitive' } }, + { phone: { contains: search } }, + ], + }), + } + + // Подсчет общего количества + const total = await prisma.employee.count({ where }) + + // Получение данных с пагинацией + const employees = await prisma.employee.findMany({ + where, + include: { + organization: true, + scheduleRecords: { + orderBy: { date: 'desc' }, + take: 10, + }, + }, + skip: (page - 1) * limit, + take: limit, + orderBy: getSortOrder(sortBy, sortOrder), + }) + + const result = { + items: employees.map(transformEmployeeToV2), + pagination: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + } + + console.log('✅ MY_EMPLOYEES DOMAIN SUCCESS:', { + total, + page, + employeesCount: employees.length + }) + + return result + } catch (error) { + console.error('❌ MY_EMPLOYEES DOMAIN ERROR:', error) + throw error + } + }), + + // Резолвер для employeesV2 (алиас для myEmployees) + employeesV2: withAuth(async (_: unknown, args: any, context: Context) => { + // Делегируем к myEmployees и возвращаем полный результат + const result = await employeeResolvers.Query.myEmployees(_, args, context); + return result; + }), + + // Получение конкретного сотрудника + employee: withAuth(async (_: unknown, args: { id: string }, context: Context) => { + const user = await checkOrganizationAccess(context.user!.id) + + const employee = await prisma.employee.findFirst({ + where: { + id: args.id, + organizationId: user.organizationId!, + }, + include: { + organization: true, + scheduleRecords: { + orderBy: { date: 'desc' }, + }, + }, + }) + + if (!employee) { + throw new GraphQLError('Сотрудник не найден') + } + + return transformEmployeeToV2(employee) + }), + + // Резолвер для employeeV2 (алиас для employee) + employeeV2: withAuth(async (_: unknown, args: { id: string }, context: Context) => { + // Делегируем к employee + return employeeResolvers.Query.employee(_, args, context); + }), + + // Получение расписания сотрудника + employeeSchedule: withAuth(async (_: unknown, args: any, context: Context) => { + const { input } = args + const { employeeId, startDate, endDate } = input + + const user = await checkOrganizationAccess(context.user!.id) + + // Проверяем, что сотрудник принадлежит организации + const employee = await prisma.employee.findFirst({ + where: { + id: employeeId, + organizationId: user.organizationId!, + }, + }) + + if (!employee) { + throw new GraphQLError('Сотрудник не найден') + } + + const scheduleRecords = await prisma.employeeSchedule.findMany({ + where: { + employeeId, + date: { + gte: new Date(startDate), + lte: new Date(endDate), + }, + }, + orderBy: { date: 'asc' }, + }) + + return scheduleRecords.map(transformScheduleToV2) + }), + + // Резолвер для employeeScheduleV2 (алиас для employeeSchedule) + employeeScheduleV2: withAuth(async (_: unknown, args: any, context: Context) => { + // Делегируем к employeeSchedule + return employeeResolvers.Query.employeeSchedule(_, args, context); + }), + }, + + Mutation: { + // ПЕРЕИМЕНОВАНО: createEmployeeV2 → createEmployee (для совместимости) + createEmployee: withAuth(async (_: unknown, args: any, context: Context) => { + console.log('🔍 CREATE EMPLOYEE DOMAIN MUTATION STARTED:', { args, userId: context.user?.id }) + const { input } = args + const user = await checkOrganizationAccess(context.user!.id) + + try { + const employee = await prisma.employee.create({ + data: { + organizationId: user.organizationId!, + firstName: input.personalInfo.firstName, + lastName: input.personalInfo.lastName, + middleName: input.personalInfo.middleName, + birthDate: input.personalInfo.birthDate, + avatar: input.personalInfo.avatar, + ...input.documentsInfo, + ...input.contactInfo, + ...input.workInfo, + }, + include: { + organization: true, + }, + }) + + const result = { + success: true, + message: 'Сотрудник успешно создан', + employee: transformEmployeeToV2(employee), + errors: [], + } + console.log('✅ CREATE EMPLOYEE DOMAIN SUCCESS:', { employeeId: employee.id, result }) + return result + } catch (error: any) { + console.error('❌ CREATE EMPLOYEE DOMAIN ERROR:', error) + throw error + } + }), + + // ПЕРЕИМЕНОВАНО: updateEmployeeV2 → updateEmployee + updateEmployee: withAuth(async (_: unknown, args: any, context: Context) => { + const { id, input } = args + const user = await checkOrganizationAccess(context.user!.id) + + try { + // Проверяем существование и принадлежность сотрудника + const existingEmployee = await prisma.employee.findFirst({ + where: { + id, + organizationId: user.organizationId!, + }, + }) + + if (!existingEmployee) { + return { + success: false, + message: 'Сотрудник не найден', + employee: null, + errors: [{ field: 'id', message: 'Сотрудник не найден' }], + } + } + + const employee = await prisma.employee.update({ + where: { id }, + data: { + ...(input.personalInfo && { + firstName: input.personalInfo.firstName, + lastName: input.personalInfo.lastName, + middleName: input.personalInfo.middleName, + birthDate: input.personalInfo.birthDate, + avatar: input.personalInfo.avatar, + }), + ...(input.documentsInfo && input.documentsInfo), + ...(input.contactInfo && input.contactInfo), + ...(input.workInfo && input.workInfo), + }, + include: { + organization: true, + }, + }) + + return { + success: true, + message: 'Сотрудник успешно обновлен', + employee: transformEmployeeToV2(employee), + errors: [], + } + } catch (error: any) { + console.error('❌ UPDATE EMPLOYEE DOMAIN ERROR:', error) + return { + success: false, + message: 'Ошибка при обновлении сотрудника', + employee: null, + errors: [{ field: 'general', message: error.message }], + } + } + }), + + // ПЕРЕИМЕНОВАНО: deleteEmployeeV2 → deleteEmployee + deleteEmployee: withAuth(async (_: unknown, args: { id: string }, context: Context) => { + const user = await checkOrganizationAccess(context.user!.id) + + try { + // Проверяем существование и принадлежность сотрудника + const existingEmployee = await prisma.employee.findFirst({ + where: { + id: args.id, + organizationId: user.organizationId!, + }, + }) + + if (!existingEmployee) { + return false + } + + await prisma.employee.delete({ + where: { id: args.id }, + }) + + return true + } catch (error: any) { + console.error('❌ DELETE EMPLOYEE DOMAIN ERROR:', error) + return false + } + }), + + // ПЕРЕИМЕНОВАНО: updateEmployeeScheduleV2 → updateEmployeeSchedule + updateEmployeeSchedule: withAuth(async (_: unknown, args: any, context: Context) => { + const { input } = args + const { employeeId, scheduleData } = input + + const user = await checkOrganizationAccess(context.user!.id) + + // Проверяем, что сотрудник принадлежит организации + const employee = await prisma.employee.findFirst({ + where: { + id: employeeId, + organizationId: user.organizationId!, + }, + }) + + if (!employee) { + throw new GraphQLError('Сотрудник не найден') + } + + // Обновление записей расписания + const scheduleRecords = await Promise.all( + scheduleData.map(async (record: any) => { + return await prisma.employeeSchedule.upsert({ + where: { + employeeId_date: { + employeeId: employeeId, + date: record.date, + }, + }, + create: { + employeeId: employeeId, + date: record.date, + status: record.status, + hoursWorked: record.hoursWorked || 0, + overtimeHours: record.overtimeHours || 0, + notes: record.notes, + }, + update: { + status: record.status, + hoursWorked: record.hoursWorked || 0, + overtimeHours: record.overtimeHours || 0, + notes: record.notes, + }, + }) + }), + ) + + return { + success: true, + message: 'Расписание сотрудника успешно обновлено', + scheduleRecords: scheduleRecords.map(transformScheduleToV2), + } + }), + + // V2 мутации (алиасы для совместимости) + createEmployeeV2: withAuth(async (_: unknown, args: any, context: Context) => { + return employeeResolvers.Mutation.createEmployee(_, args, context); + }), + + updateEmployeeV2: withAuth(async (_: unknown, args: any, context: Context) => { + return employeeResolvers.Mutation.updateEmployee(_, args, context); + }), + + deleteEmployeeV2: withAuth(async (_: unknown, args: any, context: Context) => { + return employeeResolvers.Mutation.deleteEmployee(_, args, context); + }), + + updateEmployeeScheduleV2: withAuth(async (_: unknown, args: any, context: Context) => { + return employeeResolvers.Mutation.updateEmployeeSchedule(_, args, context); + }), + }, +} + +// ============================================================================= +// 🔧 UTILITY FUNCTIONS +// ============================================================================= + +function getSortOrder(sortBy: string, sortOrder: string): any { + const orderDirection = sortOrder === 'ASC' ? 'asc' : 'desc' + + switch (sortBy) { + case 'CREATED_AT': + return { createdAt: orderDirection } + case 'LAST_NAME': + return { lastName: orderDirection } + case 'HIRE_DATE': + return { hireDate: orderDirection } + case 'DEPARTMENT': + return { department: orderDirection } + default: + return { createdAt: orderDirection } + } +} \ No newline at end of file diff --git a/src/graphql/resolvers/domains/external-ads.ts b/src/graphql/resolvers/domains/external-ads.ts new file mode 100644 index 0000000..bdb3a7f --- /dev/null +++ b/src/graphql/resolvers/domains/external-ads.ts @@ -0,0 +1,410 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' + +// External Ads Domain Resolvers - управление внешней рекламой и маркетинговыми кампаниями + +// ============================================================================= +// 🔐 AUTHENTICATION HELPERS +// ============================================================================= + +const withAuth = (resolver: any) => { + return async (parent: any, args: any, context: Context) => { + console.log('🔐 EXTERNAL ADS DOMAIN AUTH CHECK:', { + hasUser: !!context.user, + userId: context.user?.id, + organizationId: context.user?.organizationId, + }) + if (!context.user) { + console.error('❌ AUTH FAILED: No user in context') + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + console.log('✅ AUTH PASSED: Calling resolver') + try { + const result = await resolver(parent, args, context) + console.log('🎯 RESOLVER RESULT TYPE:', typeof result, result === null ? 'NULL RESULT!' : 'Has result') + return result + } catch (error) { + console.error('💥 RESOLVER ERROR:', error) + throw error + } + } +} + +// ============================================================================= +// 📢 EXTERNAL ADS DOMAIN RESOLVERS +// ============================================================================= + +export const externalAdsResolvers: DomainResolvers = { + Query: { + // Получение всех внешних рекламных кампаний за период + getExternalAds: withAuth(async ( + _: unknown, + args: { dateFrom: string; dateTo: string }, + context: Context, + ) => { + console.log('🔍 GET_EXTERNAL_ADS DOMAIN QUERY STARTED:', { + userId: context.user?.id, + dateFrom: args.dateFrom, + dateTo: args.dateTo, + }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Организация не найдена') + } + + console.log('🔍 GET_EXTERNAL_ADS: Fetching ads for organization:', { + organizationId: user.organization.id, + dateRange: `${args.dateFrom} - ${args.dateTo}`, + }) + + const externalAds = await prisma.externalAd.findMany({ + where: { + organizationId: user.organization.id, + date: { + gte: new Date(args.dateFrom), + lte: new Date(args.dateTo + 'T23:59:59.999Z'), + }, + }, + orderBy: { + date: 'desc', + }, + }) + + console.log('✅ GET_EXTERNAL_ADS DOMAIN SUCCESS:', { + organizationId: user.organization.id, + foundAds: externalAds.length, + totalCost: externalAds.reduce((sum, ad) => sum + Number(ad.cost), 0), + }) + + return { + success: true, + message: null, + externalAds: externalAds.map((ad) => ({ + ...ad, + cost: parseFloat(ad.cost.toString()), + date: ad.date.toISOString().split('T')[0], + createdAt: ad.createdAt.toISOString(), + updatedAt: ad.updatedAt.toISOString(), + })), + } + } catch (error: any) { + console.error('❌ GET_EXTERNAL_ADS DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка получения внешней рекламы', + externalAds: [], + } + } + }), + }, + + Mutation: { + // Создание новой рекламной кампании + createExternalAd: withAuth(async ( + _: unknown, + args: { + input: { + name: string + url: string + cost: number + date: string + nmId: string + } + }, + context: Context, + ) => { + console.log('🔍 CREATE_EXTERNAL_AD DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + adName: args.input.name, + cost: args.input.cost, + date: args.input.date, + nmId: args.input.nmId, + }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Организация не найдена') + } + + console.log('💰 Creating external ad:', { + organizationId: user.organization.id, + name: args.input.name, + url: args.input.url, + cost: args.input.cost, + }) + + const externalAd = await prisma.externalAd.create({ + data: { + name: args.input.name, + url: args.input.url, + cost: args.input.cost, + date: new Date(args.input.date), + nmId: args.input.nmId, + organizationId: user.organization.id, + }, + }) + + console.log('✅ CREATE_EXTERNAL_AD DOMAIN SUCCESS:', { + adId: externalAd.id, + organizationId: user.organization.id, + cost: args.input.cost, + }) + + return { + success: true, + message: 'Внешняя реклама успешно создана', + externalAd: { + ...externalAd, + cost: parseFloat(externalAd.cost.toString()), + date: externalAd.date.toISOString().split('T')[0], + createdAt: externalAd.createdAt.toISOString(), + updatedAt: externalAd.updatedAt.toISOString(), + }, + } + } catch (error: any) { + console.error('❌ CREATE_EXTERNAL_AD DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка создания внешней рекламы', + externalAd: null, + } + } + }), + + // Обновление рекламной кампании + updateExternalAd: withAuth(async ( + _: unknown, + args: { + id: string + input: { + name: string + url: string + cost: number + date: string + nmId: string + } + }, + context: Context, + ) => { + console.log('🔍 UPDATE_EXTERNAL_AD DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + adId: args.id, + newCost: args.input.cost, + newName: args.input.name, + }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Организация не найдена') + } + + // Проверяем, что реклама принадлежит организации пользователя + const existingAd = await prisma.externalAd.findFirst({ + where: { + id: args.id, + organizationId: user.organization.id, + }, + }) + + if (!existingAd) { + throw new GraphQLError('Внешняя реклама не найдена') + } + + console.log('📝 Updating external ad:', { + adId: args.id, + oldCost: Number(existingAd.cost), + newCost: args.input.cost, + organizationId: user.organization.id, + }) + + const externalAd = await prisma.externalAd.update({ + where: { id: args.id }, + data: { + name: args.input.name, + url: args.input.url, + cost: args.input.cost, + date: new Date(args.input.date), + nmId: args.input.nmId, + }, + }) + + console.log('✅ UPDATE_EXTERNAL_AD DOMAIN SUCCESS:', { + adId: externalAd.id, + updatedCost: args.input.cost, + }) + + return { + success: true, + message: 'Внешняя реклама успешно обновлена', + externalAd: { + ...externalAd, + cost: parseFloat(externalAd.cost.toString()), + date: externalAd.date.toISOString().split('T')[0], + createdAt: externalAd.createdAt.toISOString(), + updatedAt: externalAd.updatedAt.toISOString(), + }, + } + } catch (error: any) { + console.error('❌ UPDATE_EXTERNAL_AD DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка обновления внешней рекламы', + externalAd: null, + } + } + }), + + // Удаление рекламной кампании + deleteExternalAd: withAuth(async ( + _: unknown, + args: { id: string }, + context: Context, + ) => { + console.log('🔍 DELETE_EXTERNAL_AD DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + adId: args.id, + }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Организация не найдена') + } + + // Проверяем, что реклама принадлежит организации пользователя + const existingAd = await prisma.externalAd.findFirst({ + where: { + id: args.id, + organizationId: user.organization.id, + }, + }) + + if (!existingAd) { + throw new GraphQLError('Внешняя реклама не найдена') + } + + console.log('🗑️ Deleting external ad:', { + adId: args.id, + adName: existingAd.name, + adCost: Number(existingAd.cost), + organizationId: user.organization.id, + }) + + await prisma.externalAd.delete({ + where: { id: args.id }, + }) + + console.log('✅ DELETE_EXTERNAL_AD DOMAIN SUCCESS:', { + deletedAdId: args.id, + organizationId: user.organization.id, + }) + + return { + success: true, + message: 'Внешняя реклама успешно удалена', + externalAd: null, + } + } catch (error: any) { + console.error('❌ DELETE_EXTERNAL_AD DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка удаления внешней рекламы', + externalAd: null, + } + } + }), + + // Обновление количества кликов по рекламе + updateExternalAdClicks: withAuth(async ( + _: unknown, + args: { id: string; clicks: number }, + context: Context, + ) => { + console.log('🔍 UPDATE_EXTERNAL_AD_CLICKS DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + adId: args.id, + clicks: args.clicks, + }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Организация не найдена') + } + + // Проверяем, что реклама принадлежит организации пользователя + const existingAd = await prisma.externalAd.findFirst({ + where: { + id: args.id, + organizationId: user.organization.id, + }, + }) + + if (!existingAd) { + throw new GraphQLError('Внешняя реклама не найдена') + } + + console.log('👆 Updating ad clicks:', { + adId: args.id, + adName: existingAd.name, + oldClicks: existingAd.clicks || 0, + newClicks: args.clicks, + }) + + await prisma.externalAd.update({ + where: { id: args.id }, + data: { clicks: args.clicks }, + }) + + console.log('✅ UPDATE_EXTERNAL_AD_CLICKS DOMAIN SUCCESS:', { + adId: args.id, + updatedClicks: args.clicks, + }) + + return { + success: true, + message: 'Клики успешно обновлены', + externalAd: null, + } + } catch (error: any) { + console.error('❌ UPDATE_EXTERNAL_AD_CLICKS DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка обновления кликов внешней рекламы', + externalAd: null, + } + } + }), + }, +} + +console.warn('🔥 EXTERNAL ADS DOMAIN МОДУЛЬ ЭКСПОРТЫ ГОТОВЫ') \ No newline at end of file diff --git a/src/graphql/resolvers/domains/file-management.ts b/src/graphql/resolvers/domains/file-management.ts new file mode 100644 index 0000000..f59ad82 --- /dev/null +++ b/src/graphql/resolvers/domains/file-management.ts @@ -0,0 +1,308 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' + +// File Management Domain Resolvers - управление файлами и кешированием данных + +// ============================================================================= +// 🔐 AUTHENTICATION HELPERS +// ============================================================================= + +const withAuth = (resolver: any) => { + return async (parent: any, args: any, context: Context) => { + console.log('🔐 FILE MANAGEMENT DOMAIN AUTH CHECK:', { + hasUser: !!context.user, + userId: context.user?.id, + organizationId: context.user?.organizationId, + }) + if (!context.user) { + console.error('❌ AUTH FAILED: No user in context') + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + console.log('✅ AUTH PASSED: Calling resolver') + try { + const result = await resolver(parent, args, context) + console.log('🎯 RESOLVER RESULT TYPE:', typeof result, result === null ? 'NULL RESULT!' : 'Has result') + return result + } catch (error) { + console.error('💥 RESOLVER ERROR:', error) + throw error + } + } +} + +// ============================================================================= +// 📁 FILE MANAGEMENT DOMAIN RESOLVERS +// ============================================================================= + +export const fileManagementResolvers: DomainResolvers = { + Query: { + // Получение кешированных данных склада Wildberries + getWBWarehouseData: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 GET_WB_WAREHOUSE_DATA 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) { + throw new GraphQLError('Пользователь не привязан к организации') + } + + // Поиск кеша для организации + const cache = await prisma.wbWarehouseCache.findFirst({ + where: { organizationId: user.organization.id }, + orderBy: { createdAt: 'desc' }, + }) + + if (!cache) { + console.log('🔍 GET_WB_WAREHOUSE_DATA: No cache found') + return { + success: false, + message: 'Кеш данных склада не найден. Загрузите данные из API.', + data: null, + fromCache: false, + } + } + + // Проверка срока действия кеша + const now = new Date() + if (cache.expiresAt && cache.expiresAt <= now) { + console.log('🔍 GET_WB_WAREHOUSE_DATA: Cache expired') + return { + success: false, + message: 'Кеш данных склада устарел. Требуется обновление из API.', + data: null, + fromCache: false, + } + } + + // Парсинг JSON данных + let parsedData = null + if (cache.data) { + try { + parsedData = typeof cache.data === 'string' + ? JSON.parse(cache.data) + : cache.data + } catch (error) { + console.error('Error parsing cache data:', error) + return { + success: false, + message: 'Ошибка при чтении кеша данных склада', + data: null, + fromCache: false, + } + } + } + + console.log('✅ GET_WB_WAREHOUSE_DATA DOMAIN SUCCESS:', { + organizationId: user.organization.id, + hasData: !!parsedData, + cacheAge: Math.round((now.getTime() - cache.createdAt.getTime()) / 1000 / 60), // минуты + }) + + return { + success: true, + message: 'Данные склада получены из кеша', + data: parsedData, + fromCache: true, + cachedAt: cache.createdAt.toISOString(), + expiresAt: cache.expiresAt?.toISOString() || null, + } + } catch (error: any) { + console.error('❌ GET_WB_WAREHOUSE_DATA DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка при получении данных склада', + data: null, + fromCache: false, + } + } + }), + + // Получение кешированной статистики селлера + getSellerStatsCache: withAuth(async ( + _: unknown, + args: { period: string; dateFrom?: string | null; dateTo?: string | null }, + context: Context, + ) => { + console.log('🔍 GET_SELLER_STATS_CACHE DOMAIN QUERY STARTED:', { + userId: context.user?.id, + period: args.period, + dateFrom: args.dateFrom, + dateTo: args.dateTo, + }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Организация не найдена') + } + + const today = new Date() + today.setHours(0, 0, 0, 0) + + // Условия поиска кеша + const where: any = { + organizationId: user.organization.id, + cacheDate: today, + period: args.period, + } + + // Для custom периода учитываем диапазон дат + if (args.period === 'custom') { + if (!args.dateFrom || !args.dateTo) { + throw new GraphQLError('Для custom периода необходимо указать dateFrom и dateTo') + } + where.dateFrom = new Date(args.dateFrom) + where.dateTo = new Date(args.dateTo) + } + + const cache = await prisma.sellerStatsCache.findFirst({ + where, + orderBy: { createdAt: 'desc' }, + }) + + if (!cache) { + console.log('🔍 GET_SELLER_STATS_CACHE: No cache found') + return { + success: true, + message: 'Кеш не найден', + cache: null, + fromCache: false, + } + } + + // Проверка срока действия кеша + const now = new Date() + if (cache.expiresAt && cache.expiresAt <= now) { + console.log('🔍 GET_SELLER_STATS_CACHE: Cache expired') + return { + success: true, + message: 'Кеш устарел, требуется загрузка из API', + cache: null, + fromCache: false, + } + } + + console.log('✅ GET_SELLER_STATS_CACHE DOMAIN SUCCESS:', { + organizationId: user.organization.id, + period: args.period, + cacheAge: Math.round((now.getTime() - cache.createdAt.getTime()) / 1000 / 60), // минуты + }) + + return { + success: true, + message: 'Данные получены из кеша', + cache: { + ...cache, + cacheDate: cache.cacheDate.toISOString().split('T')[0], + dateFrom: cache.dateFrom ? cache.dateFrom.toISOString().split('T')[0] : null, + dateTo: cache.dateTo ? cache.dateTo.toISOString().split('T')[0] : null, + productsTotalSales: cache.productsTotalSales ? Number(cache.productsTotalSales) : null, + advertisingTotalCost: cache.advertisingTotalCost ? Number(cache.advertisingTotalCost) : null, + expiresAt: cache.expiresAt?.toISOString() || null, + createdAt: cache.createdAt.toISOString(), + updatedAt: cache.updatedAt.toISOString(), + }, + fromCache: true, + } + } catch (error: any) { + console.error('❌ GET_SELLER_STATS_CACHE DOMAIN ERROR:', error) + throw new GraphQLError(error.message || 'Ошибка при получении кеша статистики') + } + }), + }, + + Mutation: { + // Сохранение кеша данных склада WB + saveWBWarehouseCache: withAuth(async ( + _: unknown, + args: { data: any }, + context: Context, + ) => { + console.log('🔍 SAVE_WB_WAREHOUSE_CACHE DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + hasData: !!args.data, + dataSize: args.data ? JSON.stringify(args.data).length : 0, + }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Пользователь не привязан к организации') + } + + if (!args.data) { + throw new GraphQLError('Данные для сохранения не предоставлены') + } + + // Устанавливаем срок действия кеша (1 час) + const expiresAt = new Date() + expiresAt.setHours(expiresAt.getHours() + 1) + + // Сериализация данных в JSON + const serializedData = typeof args.data === 'string' + ? args.data + : JSON.stringify(args.data) + + // Удаление старого кеша для этой организации + await prisma.wbWarehouseCache.deleteMany({ + where: { organizationId: user.organization.id }, + }) + + // Сохранение нового кеша + const cache = await prisma.wbWarehouseCache.create({ + data: { + organizationId: user.organization.id, + data: serializedData, + expiresAt, + }, + }) + + console.log('✅ SAVE_WB_WAREHOUSE_CACHE DOMAIN SUCCESS:', { + organizationId: user.organization.id, + cacheId: cache.id, + dataSize: serializedData.length, + expiresAt: cache.expiresAt?.toISOString(), + }) + + return { + success: true, + message: 'Кеш данных склада успешно сохранен', + cache: { + id: cache.id, + organizationId: cache.organizationId, + createdAt: cache.createdAt.toISOString(), + expiresAt: cache.expiresAt?.toISOString() || null, + }, + fromCache: false, + } + } catch (error: any) { + console.error('❌ SAVE_WB_WAREHOUSE_CACHE DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка сохранения кеша склада WB', + cache: null, + fromCache: false, + } + } + }), + }, +} + +console.warn('🔥 FILE MANAGEMENT DOMAIN МОДУЛЬ ЭКСПОРТЫ ГОТОВЫ') \ No newline at end of file diff --git a/src/graphql/resolvers/domains/logistics-consumables.ts b/src/graphql/resolvers/domains/logistics-consumables.ts new file mode 100644 index 0000000..5ab3a0e --- /dev/null +++ b/src/graphql/resolvers/domains/logistics-consumables.ts @@ -0,0 +1,298 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { notifyOrganization } from '../../../lib/realtime' +import { DomainResolvers } from '../shared/types' + +// Logistics Consumables Domain Resolvers - управление логистикой расходников (мигрировано из logistics-consumables-v2.ts) + +// ============================================================================= +// 🔐 AUTHENTICATION HELPERS +// ============================================================================= + +const withAuth = (resolver: any) => { + return async (parent: any, args: any, context: Context) => { + console.log('🔐 LOGISTICS CONSUMABLES DOMAIN AUTH CHECK:', { + hasUser: !!context.user, + userId: context.user?.id, + organizationId: context.user?.organizationId, + }) + if (!context.user) { + console.error('❌ AUTH FAILED: No user in context') + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + console.log('✅ AUTH PASSED: Calling resolver') + try { + const result = await resolver(parent, args, context) + console.log('🎯 RESOLVER RESULT TYPE:', typeof result, result === null ? 'NULL RESULT!' : 'Has result') + return result + } catch (error) { + console.error('💥 RESOLVER ERROR:', error) + throw error + } + } +} + +const checkLogisticsAccess = 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 !== 'LOGIST') { + throw new GraphQLError('Только логистические компании могут выполнять эти операции', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + return user +} + +// ============================================================================= +// 🚚 LOGISTICS CONSUMABLES DOMAIN RESOLVERS +// ============================================================================= + +export const logisticsConsumablesResolvers: DomainResolvers = { + Query: { + // Получить V2 поставки расходников для логистической компании + myLogisticsConsumableSupplies: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_LOGISTICS_CONSUMABLE_SUPPLIES DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + const user = await checkLogisticsAccess(context.user!.id) + + // Получаем поставки где назначена наша логистическая компания + // или поставки в статусе SUPPLIER_APPROVED (ожидают назначения логистики) + const supplies = await prisma.fulfillmentConsumableSupplyOrder.findMany({ + where: { + OR: [ + // Поставки назначенные нашей логистической компании + { + logisticsPartnerId: user.organizationId!, + }, + // Поставки в статусе SUPPLIER_APPROVED (доступные для назначения) + { + status: 'SUPPLIER_APPROVED', + logisticsPartnerId: null, + }, + ], + }, + include: { + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + items: { + include: { + product: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + console.log('✅ MY_LOGISTICS_CONSUMABLE_SUPPLIES DOMAIN SUCCESS:', { count: supplies.length }) + return supplies + } catch (error) { + console.error('❌ MY_LOGISTICS_CONSUMABLE_SUPPLIES DOMAIN ERROR:', error) + return [] + } + }), + }, + + Mutation: { + // Подтверждение поставки логистикой + logisticsConfirmConsumableSupply: withAuth(async ( + _: unknown, + args: { id: string }, + context: Context, + ) => { + console.log('🔍 LOGISTICS_CONFIRM_CONSUMABLE_SUPPLY DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + supplyId: args.id + }) + try { + const user = await checkLogisticsAccess(context.user!.id) + + const supply = await prisma.fulfillmentConsumableSupplyOrder.findUnique({ + where: { id: args.id }, + include: { + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + }, + }) + + if (!supply) { + throw new GraphQLError('Поставка не найдена') + } + + // Проверяем права доступа + if (supply.logisticsPartnerId && supply.logisticsPartnerId !== user.organizationId) { + throw new GraphQLError('Нет доступа к этой поставке') + } + + // Проверяем статус - может подтвердить SUPPLIER_APPROVED или назначить себя + if (!['SUPPLIER_APPROVED'].includes(supply.status)) { + throw new GraphQLError('Поставку можно подтвердить только в статусе SUPPLIER_APPROVED') + } + + const updatedSupply = await prisma.fulfillmentConsumableSupplyOrder.update({ + where: { id: args.id }, + data: { + status: 'LOGISTICS_CONFIRMED', + logisticsPartnerId: user.organizationId, // Назначаем себя если не назначены + }, + include: { + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + // Уведомляем фулфилмент-центр о подтверждении логистикой + await notifyOrganization(supply.fulfillmentCenterId, { + type: 'supply-order:logistics-confirmed', + title: 'Логистика подтверждена', + message: `Логистическая компания "${user.organization!.name}" подтвердила поставку расходников`, + data: { + supplyOrderId: supply.id, + supplyOrderType: 'FULFILLMENT_CONSUMABLES_V2', + logisticsCompanyName: user.organization!.name, + }, + }) + + // Уведомляем поставщика + if (supply.supplierId) { + await notifyOrganization(supply.supplierId, { + type: 'supply-order:logistics-confirmed', + title: 'Логистика подтверждена', + message: `Логистическая компания "${user.organization!.name}" подтвердила поставку`, + data: { + supplyOrderId: supply.id, + supplyOrderType: 'FULFILLMENT_CONSUMABLES_V2', + logisticsCompanyName: user.organization!.name, + }, + }) + } + + const result = { + success: true, + message: 'Поставка подтверждена логистикой', + order: updatedSupply, + } + console.log('✅ LOGISTICS_CONFIRM_CONSUMABLE_SUPPLY DOMAIN SUCCESS:', { supplyId: updatedSupply.id }) + return result + } catch (error: any) { + console.error('❌ LOGISTICS_CONFIRM_CONSUMABLE_SUPPLY DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка подтверждения поставки', + order: null, + } + } + }), + + // Отклонение поставки логистикой + logisticsRejectConsumableSupply: withAuth(async ( + _: unknown, + args: { id: string; reason?: string }, + context: Context, + ) => { + console.log('🔍 LOGISTICS_REJECT_CONSUMABLE_SUPPLY DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + supplyId: args.id, + reason: args.reason + }) + try { + const user = await checkLogisticsAccess(context.user!.id) + + const supply = await prisma.fulfillmentConsumableSupplyOrder.findUnique({ + where: { id: args.id }, + include: { + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + }, + }) + + if (!supply) { + throw new GraphQLError('Поставка не найдена') + } + + // Проверяем права доступа + if (supply.logisticsPartnerId && supply.logisticsPartnerId !== 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: 'LOGISTICS_REJECTED', + logisticsNotes: args.reason, + logisticsPartnerId: null, // Убираем назначение + }, + include: { + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + // Уведомляем фулфилмент-центр об отклонении + await notifyOrganization(supply.fulfillmentCenterId, { + type: 'supply-order:logistics-rejected', + title: 'Поставка отклонена логистикой', + message: `Логистическая компания "${user.organization!.name}" отклонила поставку расходников`, + data: { + supplyOrderId: supply.id, + supplyOrderType: 'FULFILLMENT_CONSUMABLES_V2', + logisticsCompanyName: user.organization!.name, + reason: args.reason, + }, + }) + + const result = { + success: true, + message: 'Поставка отклонена логистикой', + order: updatedSupply, + } + console.log('✅ LOGISTICS_REJECT_CONSUMABLE_SUPPLY DOMAIN SUCCESS:', { supplyId: updatedSupply.id }) + return result + } catch (error: any) { + console.error('❌ LOGISTICS_REJECT_CONSUMABLE_SUPPLY DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка отклонения поставки', + order: null, + } + } + }), + }, +} + +console.warn('🔥 LOGISTICS CONSUMABLES DOMAIN МОДУЛЬ ЭКСПОРТЫ ГОТОВЫ') \ No newline at end of file diff --git a/src/graphql/resolvers/domains/logistics.ts b/src/graphql/resolvers/domains/logistics.ts new file mode 100644 index 0000000..cb571a6 --- /dev/null +++ b/src/graphql/resolvers/domains/logistics.ts @@ -0,0 +1,571 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' + +// Logistics Domain Resolvers - управление логистикой и маршрутами +export const logisticsDomainResolvers: DomainResolvers = { + Query: { + // Логистика организации + myLogistics: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + const organizationId = currentUser.organization.id + + console.warn('🚚 MY_LOGISTICS RESOLVER CALLED:', { + userId: context.user.id, + organizationId, + organizationType: currentUser.organization.type, + timestamp: new Date().toISOString(), + }) + + // Получаем логистические маршруты организации + const logistics = await prisma.logistics.findMany({ + where: { organizationId }, + include: { + organization: { + select: { + id: true, + name: true, + fullName: true, + type: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }) + + console.warn('📊 MY_LOGISTICS RESULT:', { + logisticsCount: logistics.length, + organizationType: currentUser.organization.type, + }) + + return logistics + }, + + // Логистика конкретной организации (для партнеров) + organizationLogistics: async (_: unknown, args: { organizationId: string }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + console.warn('🏢 ORGANIZATION_LOGISTICS RESOLVER CALLED:', { + requestedOrganizationId: args.organizationId, + currentOrganizationId: currentUser.organization.id, + timestamp: new Date().toISOString(), + }) + + // Проверяем права доступа - только партнеры или сама организация + let hasAccess = false + + // Если запрашиваем свою логистику + if (args.organizationId === currentUser.organization.id) { + hasAccess = true + } else { + // Проверяем, есть ли партнерство + const partnership = await prisma.counterparty.findFirst({ + where: { + organizationId: currentUser.organization.id, + counterpartyId: args.organizationId, + }, + }) + hasAccess = !!partnership + } + + if (!hasAccess) { + throw new GraphQLError('Нет доступа к логистике этой организации', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + // Получаем логистические маршруты организации + const logistics = await prisma.logistics.findMany({ + where: { organizationId: args.organizationId }, + include: { + organization: { + select: { + id: true, + name: true, + fullName: true, + type: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }) + + return logistics + }, + + // Логистические партнеры (организации-логисты) + logisticsPartners: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + console.warn('📦 LOGISTICS_PARTNERS RESOLVER CALLED:', { + organizationId: currentUser.organization.id, + organizationType: currentUser.organization.type, + timestamp: new Date().toISOString(), + }) + + // Получаем логистические компании среди контрагентов + const logisticsPartners = await prisma.counterparty.findMany({ + where: { + organizationId: currentUser.organization.id, + counterparty: { + type: 'LOGIST', + }, + }, + include: { + counterparty: { + include: { + users: true, + apiKeys: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + const partners = logisticsPartners.map((partnership) => partnership.counterparty) + + console.warn('📊 LOGISTICS_PARTNERS RESULT:', { + partnersCount: partners.length, + organizationType: currentUser.organization.type, + }) + + return partners + }, + }, + + Mutation: { + // Создать логистический маршрут + createLogistics: async ( + _: unknown, + args: { + input: { + fromAddress: string + toAddress: string + fromCoordinates?: string + toCoordinates?: string + distance?: number + estimatedTime?: number + cost?: number + vehicleType?: string + maxWeight?: number + maxVolume?: number + notes?: string + } + }, + context: Context, + ) => { + console.warn('🆕 CREATE_LOGISTICS - ВЫЗВАН:', { + fromAddress: args.input.fromAddress, + toAddress: args.input.toAddress, + vehicleType: args.input.vehicleType, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Только логистические компании могут создавать маршруты + if (currentUser.organization.type !== 'LOGIST') { + throw new GraphQLError('Только логистические компании могут создавать маршруты') + } + + try { + const logistics = await prisma.logistics.create({ + data: { + organizationId: currentUser.organization.id, + fromAddress: args.input.fromAddress, + toAddress: args.input.toAddress, + fromCoordinates: args.input.fromCoordinates, + toCoordinates: args.input.toCoordinates, + distance: args.input.distance, + estimatedTime: args.input.estimatedTime, + cost: args.input.cost, + vehicleType: args.input.vehicleType, + maxWeight: args.input.maxWeight, + maxVolume: args.input.maxVolume, + notes: args.input.notes, + }, + include: { + organization: { + select: { + id: true, + name: true, + fullName: true, + type: true, + }, + }, + }, + }) + + console.warn('✅ ЛОГИСТИЧЕСКИЙ МАРШРУТ СОЗДАН:', { + logisticsId: logistics.id, + organizationId: currentUser.organization.id, + fromAddress: args.input.fromAddress, + toAddress: args.input.toAddress, + cost: args.input.cost, + }) + + return { + success: true, + message: 'Логистический маршрут успешно создан', + logistics, + } + } catch (error) { + console.error('Error creating logistics:', error) + return { + success: false, + message: 'Ошибка при создании логистического маршрута', + logistics: null, + } + } + }, + + // Обновить логистический маршрут + updateLogistics: async ( + _: unknown, + args: { + id: string + input: { + fromAddress?: string + toAddress?: string + fromCoordinates?: string + toCoordinates?: string + distance?: number + estimatedTime?: number + cost?: number + vehicleType?: string + maxWeight?: number + maxVolume?: number + notes?: string + } + }, + context: Context, + ) => { + console.warn('📝 UPDATE_LOGISTICS - ВЫЗВАН:', { + logisticsId: args.id, + hasUpdates: Object.keys(args.input).length, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + try { + // Проверяем, что маршрут принадлежит текущей организации + const existingLogistics = await prisma.logistics.findFirst({ + where: { + id: args.id, + organizationId: currentUser.organization.id, + }, + }) + + if (!existingLogistics) { + return { + success: false, + message: 'Логистический маршрут не найден или нет доступа', + logistics: null, + } + } + + // Обновляем маршрут + const updatedLogistics = await prisma.logistics.update({ + where: { id: args.id }, + data: { + ...args.input, + updatedAt: new Date(), + }, + include: { + organization: { + select: { + id: true, + name: true, + fullName: true, + type: true, + }, + }, + }, + }) + + console.warn('✅ ЛОГИСТИЧЕСКИЙ МАРШРУТ ОБНОВЛЕН:', { + logisticsId: args.id, + organizationId: currentUser.organization.id, + updatedFields: Object.keys(args.input), + }) + + return { + success: true, + message: 'Логистический маршрут успешно обновлен', + logistics: updatedLogistics, + } + } catch (error) { + console.error('Error updating logistics:', error) + return { + success: false, + message: 'Ошибка при обновлении логистического маршрута', + logistics: null, + } + } + }, + + // Удалить логистический маршрут + deleteLogistics: async (_: unknown, args: { id: string }, context: Context) => { + console.warn('🗑️ DELETE_LOGISTICS - ВЫЗВАН:', { + logisticsId: args.id, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + try { + // Проверяем, что маршрут принадлежит текущей организации + const existingLogistics = await prisma.logistics.findFirst({ + where: { + id: args.id, + organizationId: currentUser.organization.id, + }, + }) + + if (!existingLogistics) { + return { + success: false, + message: 'Логистический маршрут не найден или нет доступа', + } + } + + // Проверяем, что маршрут не используется в активных заказах + const activeSupplyOrders = await prisma.supplyOrder.findMany({ + where: { + logisticsPartnerId: currentUser.organization.id, + status: { in: ['CONFIRMED', 'IN_TRANSIT'] }, + }, + }) + + if (activeSupplyOrders.length > 0) { + return { + success: false, + message: 'Нельзя удалить маршрут, используемый в активных заказах', + } + } + + // Удаляем маршрут + await prisma.logistics.delete({ + where: { id: args.id }, + }) + + console.warn('🗑️ ЛОГИСТИЧЕСКИЙ МАРШРУТ УДАЛЕН:', { + logisticsId: args.id, + organizationId: currentUser.organization.id, + }) + + return { + success: true, + message: 'Логистический маршрут успешно удален', + } + } catch (error) { + console.error('Error deleting logistics:', error) + return { + success: false, + message: 'Ошибка при удалении логистического маршрута', + } + } + }, + + // Назначить логистику к заказу (дублируется из supplies, но с другой логикой) + assignLogisticsToSupply: async ( + _: unknown, + args: { + supplyOrderId: string + logisticsId: string + estimatedDeliveryDate?: string + specialInstructions?: string + }, + context: Context, + ) => { + console.warn('🚛 ASSIGN_LOGISTICS - ВЫЗВАН:', { + supplyOrderId: args.supplyOrderId, + logisticsId: args.logisticsId, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + try { + // Проверяем, что это логистическая компания + if (currentUser.organization.type !== 'LOGIST') { + throw new GraphQLError('Только логистические компании могут назначать свои маршруты') + } + + // Проверяем заказ поставки + const supplyOrder = await prisma.supplyOrder.findFirst({ + where: { + id: args.supplyOrderId, + status: 'CONFIRMED', + logisticsPartnerId: currentUser.organization.id, + }, + }) + + if (!supplyOrder) { + return { + success: false, + message: 'Заказ поставки не найден или не назначен этой логистической компании', + supplyOrder: null, + } + } + + // Проверяем логистический маршрут + const logistics = await prisma.logistics.findFirst({ + where: { + id: args.logisticsId, + organizationId: currentUser.organization.id, + }, + }) + + if (!logistics) { + return { + success: false, + message: 'Логистический маршрут не найден', + supplyOrder: null, + } + } + + // Назначаем маршрут и обновляем статус + const updatedSupplyOrder = await prisma.supplyOrder.update({ + where: { id: args.supplyOrderId }, + data: { + // TODO: добавить поле logisticsId когда будет в модели + deliveryDate: args.estimatedDeliveryDate + ? new Date(args.estimatedDeliveryDate) + : supplyOrder.deliveryDate, + status: 'IN_TRANSIT', + notes: args.specialInstructions || supplyOrder.notes, + updatedAt: new Date(), + }, + include: { + organization: true, + fulfillmentCenter: true, + logisticsPartner: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + console.warn('✅ ЛОГИСТИКА НАЗНАЧЕНА НА ЗАКАЗ:', { + supplyOrderId: args.supplyOrderId, + logisticsId: args.logisticsId, + logisticsCompany: currentUser.organization.name || currentUser.organization.fullName, + route: `${logistics.fromAddress} → ${logistics.toAddress}`, + }) + + return { + success: true, + message: 'Логистический маршрут назначен на заказ', + supplyOrder: updatedSupplyOrder, + } + } catch (error) { + console.error('Error assigning logistics:', error) + return { + success: false, + message: 'Ошибка при назначении логистического маршрута', + supplyOrder: null, + } + } + }, + }, +} \ No newline at end of file diff --git a/src/graphql/resolvers/domains/messaging.ts b/src/graphql/resolvers/domains/messaging.ts new file mode 100644 index 0000000..f67ce05 --- /dev/null +++ b/src/graphql/resolvers/domains/messaging.ts @@ -0,0 +1,500 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' + +// Messaging Domain Resolvers - изолированная логика сообщений и бесед +export const messagingResolvers: DomainResolvers = { + Query: { + // Получение сообщений с контрагентом + messages: async ( + _: unknown, + args: { counterpartyId: string; limit?: number; offset?: number }, + context: Context, + ) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + const limit = args.limit || 50 + const offset = args.offset || 0 + + const messages = await prisma.message.findMany({ + where: { + OR: [ + { + senderOrganizationId: currentUser.organization.id, + receiverOrganizationId: args.counterpartyId, + }, + { + senderOrganizationId: args.counterpartyId, + receiverOrganizationId: currentUser.organization.id, + }, + ], + }, + include: { + senderOrganization: { + include: { + users: true, + }, + }, + receiverOrganization: { + include: { + users: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: limit, + skip: offset, + }) + + return messages.reverse() // Возвращаем в прямом порядке (старые -> новые) + }, + + // Получение списка бесед (разговоров) + conversations: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Получаем всех контрагентов + const counterparties = await prisma.counterparty.findMany({ + where: { organizationId: currentUser.organization.id }, + include: { + counterparty: { + include: { + users: true, + }, + }, + }, + }) + + // Для каждого контрагента получаем последнее сообщение и количество непрочитанных + const conversations = await Promise.all( + counterparties.map(async (cp) => { + // Последнее сообщение + const lastMessage = await prisma.message.findFirst({ + where: { + OR: [ + { + senderOrganizationId: currentUser.organization!.id, + receiverOrganizationId: cp.counterpartyId, + }, + { + senderOrganizationId: cp.counterpartyId, + receiverOrganizationId: currentUser.organization!.id, + }, + ], + }, + orderBy: { + createdAt: 'desc', + }, + include: { + senderOrganization: { + include: { + users: true, + }, + }, + receiverOrganization: { + include: { + users: true, + }, + }, + }, + }) + + // Количество непрочитанных сообщений от контрагента + const unreadCount = await prisma.message.count({ + where: { + senderOrganizationId: cp.counterpartyId, + receiverOrganizationId: currentUser.organization!.id, + isRead: false, + }, + }) + + return { + id: `${currentUser.organization!.id}-${cp.counterpartyId}`, + counterparty: cp.counterparty, + lastMessage, + unreadCount, + updatedAt: lastMessage?.createdAt || cp.createdAt, + } + }), + ) + + // Сортируем по времени последнего сообщения + return conversations.sort((a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ) + }, + }, + + Mutation: { + // Отправка текстового сообщения + sendMessage: async ( + _: unknown, + args: { + receiverOrganizationId: string + content?: string + type?: 'TEXT' | 'VOICE' + }, + context: Context, + ) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверяем, что получатель является контрагентом + const isCounterparty = await prisma.counterparty.findFirst({ + where: { + organizationId: currentUser.organization.id, + counterpartyId: args.receiverOrganizationId, + }, + }) + + if (!isCounterparty) { + throw new GraphQLError('Можно отправлять сообщения только контрагентам') + } + + // Получаем организацию получателя + const receiverOrganization = await prisma.organization.findUnique({ + where: { id: args.receiverOrganizationId }, + }) + + if (!receiverOrganization) { + throw new GraphQLError('Организация получателя не найдена') + } + + // Создаем сообщение + const message = await prisma.message.create({ + data: { + content: args.content?.trim() || null, + type: args.type || 'TEXT', + senderId: context.user.id, + senderOrganizationId: currentUser.organization.id, + receiverOrganizationId: args.receiverOrganizationId, + }, + include: { + senderOrganization: { + include: { + users: true, + }, + }, + receiverOrganization: { + include: { + users: true, + }, + }, + }, + }) + + return message + }, + + // Отправка голосового сообщения + sendVoiceMessage: async ( + _: unknown, + args: { + receiverOrganizationId: string + voiceUrl: string + voiceDuration: number + }, + context: Context, + ) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверяем, что получатель является контрагентом + const isCounterparty = await prisma.counterparty.findFirst({ + where: { + organizationId: currentUser.organization.id, + counterpartyId: args.receiverOrganizationId, + }, + }) + + if (!isCounterparty) { + throw new GraphQLError('Можно отправлять сообщения только контрагентам') + } + + // Получаем организацию получателя + const receiverOrganization = await prisma.organization.findUnique({ + where: { id: args.receiverOrganizationId }, + }) + + if (!receiverOrganization) { + throw new GraphQLError('Организация получателя не найдена') + } + + // Создаем голосовое сообщение + const message = await prisma.message.create({ + data: { + content: 'Голосовое сообщение', + type: 'VOICE', + senderId: context.user.id, + senderOrganizationId: currentUser.organization.id, + receiverOrganizationId: args.receiverOrganizationId, + voiceUrl: args.voiceUrl, + voiceDuration: args.voiceDuration, + }, + include: { + senderOrganization: { + include: { + users: true, + }, + }, + receiverOrganization: { + include: { + users: true, + }, + }, + }, + }) + + return message + }, + + // Отправка изображения + sendImageMessage: async ( + _: unknown, + args: { + receiverOrganizationId: string + fileUrl: string + fileName: string + fileSize: number + fileType: string + }, + context: Context, + ) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверяем, что получатель является контрагентом + const isCounterparty = await prisma.counterparty.findFirst({ + where: { + organizationId: currentUser.organization.id, + counterpartyId: args.receiverOrganizationId, + }, + }) + + if (!isCounterparty) { + throw new GraphQLError('Можно отправлять сообщения только контрагентам') + } + + // Получаем организацию получателя + const receiverOrganization = await prisma.organization.findUnique({ + where: { id: args.receiverOrganizationId }, + }) + + if (!receiverOrganization) { + throw new GraphQLError('Организация получателя не найдена') + } + + // Создаем сообщение с изображением + const message = await prisma.message.create({ + data: { + content: `Изображение: ${args.fileName}`, + type: 'IMAGE', + senderId: context.user.id, + senderOrganizationId: currentUser.organization.id, + receiverOrganizationId: args.receiverOrganizationId, + fileUrl: args.fileUrl, + fileName: args.fileName, + fileSize: args.fileSize, + fileType: args.fileType, + }, + include: { + senderOrganization: { + include: { + users: true, + }, + }, + receiverOrganization: { + include: { + users: true, + }, + }, + }, + }) + + return message + }, + + // Отправка файла + sendFileMessage: async ( + _: unknown, + args: { + receiverOrganizationId: string + fileUrl: string + fileName: string + fileSize: number + fileType: string + }, + context: Context, + ) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверяем, что получатель является контрагентом + const isCounterparty = await prisma.counterparty.findFirst({ + where: { + organizationId: currentUser.organization.id, + counterpartyId: args.receiverOrganizationId, + }, + }) + + if (!isCounterparty) { + throw new GraphQLError('Можно отправлять сообщения только контрагентам') + } + + // Получаем организацию получателя + const receiverOrganization = await prisma.organization.findUnique({ + where: { id: args.receiverOrganizationId }, + }) + + if (!receiverOrganization) { + throw new GraphQLError('Организация получателя не найдена') + } + + // Создаем сообщение с файлом + const message = await prisma.message.create({ + data: { + content: `Файл: ${args.fileName}`, + type: 'FILE', + senderId: context.user.id, + senderOrganizationId: currentUser.organization.id, + receiverOrganizationId: args.receiverOrganizationId, + fileUrl: args.fileUrl, + fileName: args.fileName, + fileSize: args.fileSize, + fileType: args.fileType, + }, + include: { + senderOrganization: { + include: { + users: true, + }, + }, + receiverOrganization: { + include: { + users: true, + }, + }, + }, + }) + + return message + }, + + // Отметка сообщений как прочитанных + markMessagesAsRead: async (_: unknown, args: { conversationId: string }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // conversationId имеет формат "currentOrgId-counterpartyId" + const [, counterpartyId] = args.conversationId.split('-') + + if (!counterpartyId) { + throw new GraphQLError('Неверный ID беседы') + } + + // Помечаем все непрочитанные сообщения от контрагента как прочитанные + await prisma.message.updateMany({ + where: { + senderOrganizationId: counterpartyId, + receiverOrganizationId: currentUser.organization.id, + isRead: false, + }, + data: { + isRead: true, + }, + }) + + return true + }, + }, +} \ No newline at end of file diff --git a/src/graphql/resolvers/domains/organization-management.ts b/src/graphql/resolvers/domains/organization-management.ts new file mode 100644 index 0000000..9eff957 --- /dev/null +++ b/src/graphql/resolvers/domains/organization-management.ts @@ -0,0 +1,830 @@ +import { GraphQLError } from 'graphql' +import * as jwt from 'jsonwebtoken' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' +import { DaDataService } from '../../../services/dadata-service' +import { apiKeyUtility, ApiKeyInput } from '../shared/api-keys' + +// Типы для JWT токена +interface AuthTokenPayload { + userId: string + phone: string +} + +// JWT утилита +const generateToken = (payload: AuthTokenPayload): string => { + return jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: '30d' }) +} + +// DaData сервис +const dadataService = new DaDataService() + +// Organization Management Domain Resolvers - управление организациями +export const organizationManagementResolvers: DomainResolvers = { + Query: { + // Получить организацию по ID + organization: async (_: unknown, args: { id: string }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const organization = await prisma.organization.findUnique({ + where: { id: args.id }, + include: { + apiKeys: true, + users: true, + }, + }) + + if (!organization) { + throw new GraphQLError('Организация не найдена') + } + + // Проверяем, что пользователь имеет доступ к этой организации + const hasAccess = organization.users.some((user) => user.id === context.user!.id) + if (!hasAccess) { + throw new GraphQLError('Нет доступа к этой организации', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + return organization + }, + + // Поиск организаций + searchOrganizations: async (_: unknown, args: { type?: string; search?: string }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + // Получаем текущую организацию пользователя + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Получаем уже существующих контрагентов для добавления флага + const existingCounterparties = await prisma.counterparty.findMany({ + where: { organizationId: currentUser.organization.id }, + select: { counterpartyId: true }, + }) + + const existingCounterpartyIds = existingCounterparties.map((c) => c.counterpartyId) + + // Получаем исходящие заявки для добавления флага hasOutgoingRequest + const outgoingRequests = await prisma.counterpartyRequest.findMany({ + where: { + senderId: currentUser.organization.id, + status: 'PENDING', + }, + select: { receiverId: true }, + }) + + const outgoingRequestIds = outgoingRequests.map((r) => r.receiverId) + + // Строим условия поиска + const whereConditions: Record = { + id: { not: currentUser.organization.id }, // Исключаем свою организацию + } + + // Фильтр по типу организации + if (args.type) { + whereConditions.type = args.type + } + + // Фильтр по тексту поиска + if (args.search) { + whereConditions.OR = [ + { name: { contains: args.search, mode: 'insensitive' } }, + { fullName: { contains: args.search, mode: 'insensitive' } }, + { inn: { contains: args.search, mode: 'insensitive' } }, + ] + } + + const organizations = await prisma.organization.findMany({ + where: whereConditions, + include: { + users: true, + apiKeys: true, + }, + take: 50, // Ограничиваем результаты + orderBy: { createdAt: 'desc' }, + }) + + // Добавляем флаги для каждой организации + return organizations.map((org) => ({ + ...org, + isCounterparty: existingCounterpartyIds.includes(org.id), + hasOutgoingRequest: outgoingRequestIds.includes(org.id), + isCurrentUser: false, // Уже исключили текущую организацию выше + })) + }, + }, + + Mutation: { + // Регистрация фулфилмент организации + registerFulfillmentOrganization: async ( + _: unknown, + args: { + input: { + phone: string + inn: string + type: 'FULFILLMENT' | 'LOGIST' | 'WHOLESALE' + kpp?: string + name?: string + fullName?: string + address?: string + addressFull?: string + ogrn?: string + ogrnDate?: string + managerName?: string + managerEmail?: string + managerPhone?: string + description?: string + logoUrl?: string + website?: string + bankName?: string + bik?: string + correspondentAccount?: string + currentAccount?: string + taxSystem?: string + referralCode?: string + partnerCode?: string + } + }, + context: Context, + ) => { + console.warn('🏢 REGISTER_FULFILLMENT_ORGANIZATION - ВЫЗВАН:', { + phone: args.input.phone, + inn: args.input.inn, + referralCode: args.input.referralCode, + partnerCode: args.input.partnerCode, + timestamp: new Date().toISOString(), + }) + + try { + // Проверяем уникальность ИНН + const existingOrganization = await prisma.organization.findFirst({ + where: { inn: args.input.inn }, + }) + + if (existingOrganization) { + return { + success: false, + message: 'Организация с таким ИНН уже зарегистрирована', + token: null, + user: null, + } + } + + // Проверяем уникальность телефона + const existingUser = await prisma.user.findUnique({ + where: { phone: args.input.phone }, + }) + + if (existingUser && existingUser.organizationId) { + return { + success: false, + message: 'Пользователь с таким телефоном уже зарегистрирован в системе', + token: null, + user: null, + } + } + + // Обработка реферального кода + let referredByOrganization = null + if (args.input.referralCode) { + referredByOrganization = await prisma.organization.findUnique({ + where: { referralCode: args.input.referralCode }, + }) + + if (!referredByOrganization) { + console.warn('⚠️ Неверный реферальный код:', args.input.referralCode) + return { + success: false, + message: 'Неверный реферальный код', + token: null, + user: null, + } + } + } + + // Получаем данные организации из DaData + console.warn('🔍 Получение данных организации из DaData для ИНН:', args.input.inn) + const organizationData = await dadataService.getOrganizationByInn(args.input.inn) + + if (!organizationData) { + console.error('❌ Данные организации не найдены в DaData:', args.input.inn) + return { + success: false, + message: 'Организация с таким ИНН не найдена в реестре', + token: null, + user: null, + } + } + + console.warn('✅ Данные из DaData получены:', { + name: organizationData.name, + fullName: organizationData.fullName, + address: organizationData.address, + isActive: organizationData.isActive, + }) + + // Создаем организацию + const organization = await prisma.organization.create({ + data: { + inn: args.input.inn, + kpp: organizationData.kpp || args.input.kpp, + name: organizationData.name || args.input.name, + fullName: organizationData.fullName || args.input.fullName, + address: organizationData.address || args.input.address, + addressFull: organizationData.addressFull || args.input.addressFull, + ogrn: organizationData.ogrn || args.input.ogrn, + ogrnDate: organizationData.ogrnDate || (args.input.ogrnDate ? new Date(args.input.ogrnDate) : null), + type: args.input.type, + // Дополнительные данные из DaData + status: organizationData.status || null, + actualityDate: organizationData.actualityDate || null, + registrationDate: organizationData.registrationDate || null, + liquidationDate: organizationData.liquidationDate || null, + managementName: organizationData.managementName || null, + managementPost: organizationData.managementPost || null, + opfCode: organizationData.opfCode || null, + opfFull: organizationData.opfFull || null, + opfShort: organizationData.opfShort || null, + okato: organizationData.okato || null, + oktmo: organizationData.oktmo || null, + okpo: organizationData.okpo || null, + okved: organizationData.okved || null, + employeeCount: organizationData.employeeCount || null, + revenue: organizationData.revenue ? BigInt(organizationData.revenue) : null, + taxSystem: organizationData.taxSystem || args.input.taxSystem, + phones: organizationData.phones ? JSON.stringify(organizationData.phones) : null, + emails: organizationData.emails ? JSON.stringify(organizationData.emails) : null, + referralCode: `FF_${args.input.inn}_${Date.now()}`, + referredById: referredByOrganization?.id, + }, + include: { + apiKeys: true, + }, + }) + + // Создаем или обновляем пользователя + let user + if (existingUser) { + user = await prisma.user.update({ + where: { id: existingUser.id }, + data: { organizationId: organization.id }, + include: { + organization: { + include: { + apiKeys: true, + }, + }, + }, + }) + } else { + user = await prisma.user.create({ + data: { + phone: args.input.phone, + organizationId: organization.id, + }, + include: { + organization: { + include: { + apiKeys: true, + }, + }, + }, + }) + } + + // Обработка партнерского кода (автопартнерство) + if (args.input.partnerCode) { + try { + console.warn('🔍 ПАРТНЕРСКИЙ КОД ПРОВЕРКА:', { + partnerCode: args.input.partnerCode, + hasPartnerCode: !!args.input.partnerCode, + partnerCodeLength: args.input.partnerCode?.length, + }) + + console.warn('🔍 ПОИСК ПАРТНЕРА ПО КОДУ:', args.input.partnerCode) + + // Находим партнера по партнерскому коду + const partner = await prisma.organization.findUnique({ + where: { referralCode: args.input.partnerCode }, + }) + + console.warn('🔍 РЕЗУЛЬТАТ ПОИСКА ПАРТНЕРА:', { + found: !!partner, + partnerId: partner?.id, + partnerName: partner?.name, + partnerType: partner?.type, + }) + + if (partner) { + console.warn('🎯 СОЗДАНИЕ AUTO_PARTNERSHIP:', { + referrerId: partner.id, + referralId: organization.id, + points: 100, + }) + + // Создаем реферальную транзакцию (100 сфер) + await prisma.referralTransaction.create({ + data: { + referrerId: partner.id, + referralId: organization.id, + points: 100, + type: 'AUTO_PARTNERSHIP', + description: `Регистрация ${args.input.type.toLowerCase()} организации по партнерской ссылке`, + }, + }) + + // Увеличиваем счетчик сфер у партнера + await prisma.organization.update({ + where: { id: partner.id }, + data: { referralPoints: { increment: 100 } }, + }) + + // Устанавливаем связь реферала и источник регистрации + await prisma.organization.update({ + where: { id: organization.id }, + data: { referredById: partner.id }, + }) + + // Создаем партнерскую связь (автоматическое добавление в контрагенты) + await prisma.counterparty.create({ + data: { + organizationId: partner.id, + counterpartyId: organization.id, + type: 'AUTO', + triggeredBy: 'PARTNER_LINK', + }, + }) + + await prisma.counterparty.create({ + data: { + organizationId: organization.id, + counterpartyId: partner.id, + type: 'AUTO', + triggeredBy: 'PARTNER_LINK', + }, + }) + + console.warn('🤝 Автоматическое партнерство создано по partnerCode:', { + organizationId: organization.id, + partnerId: partner.id, + referralPoints: 100, + type: args.input.type, + }) + } else { + console.warn('⚠️ Партнер не найден по коду:', args.input.partnerCode) + } + } catch (partnerError) { + console.warn('⚠️ Ошибка обработки партнерского кода:', partnerError) + } + } + + // Начисляем реферальные баллы (если есть реферер) + if (referredByOrganization) { + await prisma.organization.update({ + where: { id: referredByOrganization.id }, + data: { + referralPoints: { + increment: 100, // 100 баллов за привлечение фулфилмента + }, + }, + }) + + console.warn('💰 Начислены реферальные баллы:', { + referrerId: referredByOrganization.id, + points: 100, + newOrganizationId: organization.id, + }) + } + + // Создаем JWT токен + const token = generateToken({ + userId: user.id, + phone: user.phone, + }) + + console.warn('✅ ФУЛФИЛМЕНТ ОРГАНИЗАЦИЯ СОЗДАНА:', { + organizationId: organization.id, + userId: user.id, + inn: organization.inn, + type: organization.type, + referralCode: organization.referralCode, + }) + + return { + success: true, + message: 'Фулфилмент организация успешно зарегистрирована', + token, + user, + } + } catch (error) { + console.error('Error registering fulfillment organization:', error) + return { + success: false, + message: 'Ошибка при регистрации организации', + token: null, + user: null, + } + } + }, + + // Регистрация селлер организации + registerSellerOrganization: async ( + _: unknown, + args: { + input: { + phone: string + wbApiKey?: string + ozonApiKey?: string + ozonClientId?: string + referralCode?: string + partnerCode?: string + } + }, + context: Context, + ) => { + console.warn('🛍️ REGISTER_SELLER_ORGANIZATION - ВЫЗВАН:', { + phone: args.input.phone, + wbApiKey: args.input.wbApiKey ? '[СКРЫТ]' : undefined, + ozonApiKey: args.input.ozonApiKey ? '[СКРЫТ]' : undefined, + partnerCode: args.input.partnerCode, + referralCode: args.input.referralCode, + timestamp: new Date().toISOString(), + }) + + try { + // УБРАНА ПРОВЕРКА ИНН - селлеры не требуют ИНН для регистрации + + // Проверяем уникальность телефона + const existingUser = await prisma.user.findUnique({ + where: { phone: args.input.phone }, + }) + + if (existingUser && existingUser.organizationId) { + return { + success: false, + message: 'Пользователь с таким телефоном уже зарегистрирован в системе', + token: null, + user: null, + } + } + + // Обработка реферального кода + let referredByOrganization = null + if (args.input.referralCode) { + referredByOrganization = await prisma.organization.findUnique({ + where: { referralCode: args.input.referralCode }, + }) + + if (!referredByOrganization) { + console.warn('⚠️ Неверный реферальный код:', args.input.referralCode) + return { + success: false, + message: 'Неверный реферальный код', + token: null, + user: null, + } + } + } + + // Создаем организацию селлера с псевдо-ИНН (как в старом коде) + const organization = await prisma.organization.create({ + data: { + inn: `SELLER_${Date.now()}`, // Псевдо-ИНН для селлеров + type: 'SELLER', + name: `Селлер ${args.input.phone}`, // Временное название на основе телефона + referralCode: `SL_${args.input.phone.replace(/\D/g, '')}_${Date.now()}`, + referredById: referredByOrganization?.id, + }, + include: { + apiKeys: true, + }, + }) + + // Создаем или обновляем пользователя + let user + if (existingUser) { + user = await prisma.user.update({ + where: { id: existingUser.id }, + data: { organizationId: organization.id }, + include: { + organization: { + include: { + apiKeys: true, + }, + }, + }, + }) + } else { + user = await prisma.user.create({ + data: { + phone: args.input.phone, + organizationId: organization.id, + }, + include: { + organization: { + include: { + apiKeys: true, + }, + }, + }, + }) + } + + // Обработка партнерского кода (автопартнерство) + console.warn('🔍 ПАРТНЕРСКИЙ КОД ПРОВЕРКА:', { + partnerCode: args.input.partnerCode, + hasPartnerCode: !!args.input.partnerCode, + partnerCodeLength: args.input.partnerCode?.length, + }) + + if (args.input.partnerCode) { + try { + console.warn('🔍 ПОИСК ПАРТНЕРА ПО КОДУ:', args.input.partnerCode) + + // Находим партнера по партнерскому коду + const partner = await prisma.organization.findUnique({ + where: { referralCode: args.input.partnerCode }, + }) + + console.warn('🔍 РЕЗУЛЬТАТ ПОИСКА ПАРТНЕРА:', { + found: !!partner, + partnerId: partner?.id, + partnerName: partner?.name, + partnerType: partner?.type, + }) + + if (partner) { + console.warn('🎯 СОЗДАНИЕ AUTO_PARTNERSHIP:', { + referrerId: partner.id, + referralId: organization.id, + points: 100, + }) + + // Создаем реферальную транзакцию (100 сфер) + await prisma.referralTransaction.create({ + data: { + referrerId: partner.id, + referralId: organization.id, + points: 100, + type: 'AUTO_PARTNERSHIP', + description: 'Регистрация селлер организации по партнерской ссылке', + }, + }) + + // Увеличиваем счетчик сфер у партнера + await prisma.organization.update({ + where: { id: partner.id }, + data: { referralPoints: { increment: 100 } }, + }) + + // Устанавливаем связь реферала и источник регистрации + await prisma.organization.update({ + where: { id: organization.id }, + data: { referredById: partner.id }, + }) + + // Создаем партнерскую связь (автоматическое добавление в контрагенты) + await prisma.counterparty.create({ + data: { + organizationId: partner.id, + counterpartyId: organization.id, + type: 'AUTO', + triggeredBy: 'PARTNER_LINK', + }, + }) + + await prisma.counterparty.create({ + data: { + organizationId: organization.id, + counterpartyId: partner.id, + type: 'AUTO', + triggeredBy: 'PARTNER_LINK', + }, + }) + + console.warn('🤝 Автоматическое партнерство создано по partnerCode:', { + organizationId: organization.id, + partnerId: partner.id, + referralPoints: 100, + }) + } + } catch (partnerError) { + console.warn('⚠️ Ошибка обработки партнерского кода:', partnerError) + } + } + + // Начисляем реферальные баллы (если есть реферер) + if (referredByOrganization) { + await prisma.organization.update({ + where: { id: referredByOrganization.id }, + data: { + referralPoints: { + increment: 50, // 50 баллов за привлечение селлера + }, + }, + }) + + console.warn('💰 Начислены реферальные баллы:', { + referrerId: referredByOrganization.id, + points: 50, + newOrganizationId: organization.id, + }) + } + + // Обработка API ключей маркетплейсов + const apiKeysToCreate: ApiKeyInput[] = [] + + if (args.input.wbApiKey) { + apiKeysToCreate.push({ + marketplace: 'WILDBERRIES', + apiKey: args.input.wbApiKey, + }) + } + + if (args.input.ozonApiKey && args.input.ozonClientId) { + apiKeysToCreate.push({ + marketplace: 'OZON', + apiKey: args.input.ozonApiKey, + clientId: args.input.ozonClientId, + }) + } + + // Сохраняем API ключи в базу данных + if (apiKeysToCreate.length > 0) { + console.warn('🔑 СОХРАНЕНИЕ API КЛЮЧЕЙ ДЛЯ СЕЛЛЕРА:', { + organizationId: organization.id, + keysCount: apiKeysToCreate.length, + marketplaces: apiKeysToCreate.map(k => k.marketplace), + }) + + const apiKeysResult = await apiKeyUtility.createMultipleApiKeys( + organization.id, + apiKeysToCreate, + ) + + if (apiKeysResult.success) { + console.warn('✅ ВСЕ API КЛЮЧИ СОХРАНЕНЫ УСПЕШНО:', { + organizationId: organization.id, + savedKeys: apiKeysResult.results.filter(r => r.success).length, + totalKeys: apiKeysResult.results.length, + }) + + // Обновляем название организации данными продавца из маркетплейса + if (apiKeysResult.sellerData) { + const { sellerName, tradeMark } = apiKeysResult.sellerData + const organizationName = tradeMark || sellerName || `Селлер ${args.input.phone}` + + console.warn('🏪 ОБНОВЛЕНИЕ НАЗВАНИЯ ОРГАНИЗАЦИИ:', { + organizationId: organization.id, + oldName: organization.name, + newName: organizationName, + source: tradeMark ? 'tradeMark' : sellerName ? 'sellerName' : 'fallback', + }) + + await prisma.organization.update({ + where: { id: organization.id }, + data: { + name: organizationName, + fullName: sellerName || tradeMark || null, + }, + }) + + console.warn('✅ НАЗВАНИЕ ОРГАНИЗАЦИИ ОБНОВЛЕНО:', { + organizationId: organization.id, + name: organizationName, + }) + } + } else { + console.warn('⚠️ НЕКОТОРЫЕ API КЛЮЧИ НЕ СОХРАНЕНЫ:', { + organizationId: organization.id, + failed: apiKeysResult.results.filter(r => !r.success).map(r => r.message), + }) + } + } + + // Создаем JWT токен + const token = generateToken({ + userId: user.id, + phone: user.phone, + }) + + console.warn('✅ СЕЛЛЕР ОРГАНИЗАЦИЯ СОЗДАНА:', { + organizationId: organization.id, + userId: user.id, + type: organization.type, + referralCode: organization.referralCode, + }) + + return { + success: true, + message: 'Селлер организация успешно зарегистрирована', + token, + user, + } + } catch (error) { + console.error('Error registering seller organization:', error) + return { + success: false, + message: 'Ошибка при регистрации организации', + token: null, + user: null, + } + } + }, + + // Обновление организации по ИНН + updateOrganizationByInn: async (_: unknown, args: { inn: string }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + try { + // TODO: Интеграция с DaData для получения данных по ИНН + // const dadataInfo = await dadataService.getOrganizationByInn(args.inn) + + // Заглушка для демо + const updatedOrganization = await prisma.organization.update({ + where: { id: currentUser.organization.id }, + data: { + inn: args.inn, + // Здесь будут данные из DaData + updatedAt: new Date(), + }, + include: { + apiKeys: true, + users: true, + }, + }) + + return { + success: true, + message: 'Организация обновлена по ИНН', + organization: updatedOrganization, + } + } catch (error) { + console.error('Error updating organization by INN:', error) + return { + success: false, + message: 'Ошибка при обновлении организации', + organization: null, + } + } + }, + }, + + // Типовой резолвер для Organization + Organization: { + users: async (parent: { id: string; users?: unknown[] }) => { + // Если пользователи уже загружены через include, возвращаем их + if (parent.users) { + return parent.users + } + + // Иначе загружаем отдельно + return await prisma.user.findMany({ + where: { organizationId: parent.id }, + }) + }, + services: async (parent: { id: string; services?: unknown[] }) => { + // Если услуги уже загружены через include, возвращаем их + if (parent.services) { + return parent.services + } + + // Иначе загружаем отдельно + return await prisma.service.findMany({ + where: { organizationId: parent.id }, + include: { organization: true }, + orderBy: { createdAt: 'desc' }, + }) + }, + }, +} \ No newline at end of file diff --git a/src/graphql/resolvers/domains/products.ts b/src/graphql/resolvers/domains/products.ts new file mode 100644 index 0000000..0499698 --- /dev/null +++ b/src/graphql/resolvers/domains/products.ts @@ -0,0 +1,770 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' +import { getCurrentUser, requireWholesaleAccess, withOrgTypeAuth } from '../shared/auth-utils' + +// Products Domain Resolvers - изолированная логика товаров и расходников +export const productsResolvers: DomainResolvers = { + Query: { + // Товары поставщика - ОПТИМИЗИРОВАНО + myProducts: withOrgTypeAuth(['WHOLESALE'], async (_: unknown, __: unknown, context: Context, user: any) => { + const products = await prisma.product.findMany({ + where: { + organizationId: user.organizationId, + }, + select: { + id: true, + name: true, + article: true, + description: true, + type: true, + isActive: true, + price: true, + pricePerSet: true, + quantity: true, + setQuantity: true, + ordered: true, + inTransit: true, + stock: true, + sold: true, + brand: true, + color: true, + size: true, + weight: true, + dimensions: true, + material: true, + images: true, + mainImage: true, + createdAt: true, + updatedAt: true, + // Оптимизированные select для связанных объектов + category: { + select: { id: true, name: true } + }, + organization: { + select: { id: true, name: true, type: true, market: true } + }, + }, + orderBy: { createdAt: 'desc' }, + take: 200, // Добавляем лимит для производительности + }) + + console.warn('🔥 MY_PRODUCTS RESOLVER DEBUG:', { + userId: user.id, + organizationId: user.organizationId, + organizationType: user.organization.type, + organizationName: user.organization.name, + totalProducts: products.length, + }) + + return products + }), + + // Товары на складе фулфилмента (из доставленных заказов поставок) + warehouseProducts: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { + organization: true, + }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверяем, что это фулфилмент центр + if (currentUser.organization.type !== 'FULFILLMENT') { + throw new GraphQLError('Товары склада доступны только для фулфилмент центров') + } + + // Получаем все доставленные заказы поставок, где этот фулфилмент центр является получателем + const deliveredSupplyOrders = await prisma.supplyOrder.findMany({ + where: { + fulfillmentCenterId: currentUser.organization.id, + status: 'DELIVERED', // Только доставленные заказы + }, + include: { + items: { + include: { + product: { + include: { + category: true, + organization: true, // Включаем информацию о поставщике + }, + }, + }, + }, + organization: true, // Селлер, который сделал заказ + partner: true, // Поставщик товаров + }, + }) + + // Собираем все товары из доставленных заказов + const allProducts: unknown[] = [] + + console.warn('🔍 Резолвер warehouseProducts (доставленные заказы):', { + currentUserId: currentUser.id, + organizationId: currentUser.organization.id, + organizationType: currentUser.organization.type, + deliveredOrdersCount: deliveredSupplyOrders.length, + orders: deliveredSupplyOrders.map((order) => ({ + id: order.id, + sellerName: order.organization.name || order.organization.fullName, + supplierName: order.partner.name || order.partner.fullName, + status: order.status, + itemsCount: order.items.length, + deliveryDate: order.deliveryDate, + })), + }) + + for (const order of deliveredSupplyOrders) { + console.warn( + `📦 Заказ от селлера ${order.organization.name} у поставщика ${order.partner.name}:`, + order.items.map((item) => ({ + productId: item.product.id, + productName: item.product.name, + article: item.product.article, + orderedQuantity: item.quantity, + price: item.price, + })), + ) + + for (const item of order.items) { + // Добавляем только товары типа PRODUCT, исключаем расходники + if (item.product.type === 'PRODUCT') { + allProducts.push({ + ...item.product, + // Дополнительная информация о заказе + orderedQuantity: item.quantity, + orderedPrice: item.price, + orderId: order.id, + orderDate: order.deliveryDate, + seller: order.organization, // Селлер, который заказал + supplier: order.partner, // Поставщик товара + // Для совместимости с существующим интерфейсом + organization: order.organization, // Указываем селлера как владельца + }) + } else { + console.warn('🚫 Исключен расходник из основного склада фулфилмента:', { + name: item.product.name, + type: item.product.type, + orderId: order.id, + }) + } + } + } + + console.warn('✅ Итого товаров на складе фулфилмента (из доставленных заказов):', allProducts.length) + return allProducts + }, + + // Данные склада с партнерами (3-уровневая иерархия) + warehouseData: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверяем, что это фулфилмент центр + if (currentUser.organization.type !== 'FULFILLMENT') { + throw new GraphQLError('Данные склада доступны только для фулфилмент центров') + } + + console.warn('🏪 WAREHOUSE DATA: Получение данных склада для фулфилмента', currentUser.organization.id) + + // Получаем всех партнеров-селлеров + const counterparties = await prisma.counterparty.findMany({ + where: { + organizationId: currentUser.organization.id, + }, + include: { + counterparty: true, + }, + }) + + const sellerPartners = counterparties.filter((c) => c.counterparty.type === 'SELLER') + + console.warn('🤝 PARTNERS FOUND:', { + totalCounterparties: counterparties.length, + sellerPartners: sellerPartners.length, + sellers: sellerPartners.map((p) => ({ + id: p.counterparty.id, + name: p.counterparty.name, + fullName: p.counterparty.fullName, + inn: p.counterparty.inn, + })), + }) + + // Создаем данные склада для каждого партнера-селлера + const stores = sellerPartners.map((partner) => { + const org = partner.counterparty + + // ЛОГИКА ОПРЕДЕЛЕНИЯ НАЗВАНИЯ МАГАЗИНА: + // 1. Если есть name и оно не содержит "ИП" - используем name + // 2. Если есть fullName и name содержит "ИП" - извлекаем из fullName название в скобках + // 3. Fallback к name или fullName + let storeName = org.name + + if (org.fullName && org.name?.includes('ИП')) { + // Извлекаем название из скобок, например: "ИП Антипова Д. В. (Renrel)" -> "Renrel" + const match = org.fullName.match(/\(([^)]+)\)/) + if (match && match[1]) { + storeName = match[1] + } + } + + return { + id: `store_${org.id}`, + storeName: storeName || org.fullName || org.name, + storeOwner: org.inn || org.fullName || org.name, + storeImage: org.logoUrl || null, + storeQuantity: 0, // Пока без поставок + partnershipDate: partner.createdAt || new Date(), + products: [], // Пустой массив продуктов + } + }) + + // Сортировка: новые партнеры (quantity = 0) в самом верху + stores.sort((a, b) => { + if (a.storeQuantity === 0 && b.storeQuantity > 0) return -1 + if (a.storeQuantity > 0 && b.storeQuantity === 0) return 1 + return b.storeQuantity - a.storeQuantity + }) + + console.warn('📦 WAREHOUSE STORES CREATED:', { + storesCount: stores.length, + storesPreview: stores.slice(0, 3).map((s) => ({ + storeName: s.storeName, + storeOwner: s.storeOwner, + storeQuantity: s.storeQuantity, + })), + }) + + return { + stores, + } + }, + + // Все товары и расходники поставщиков для маркета + allProducts: async (_: unknown, args: { search?: string; category?: string }, context: Context) => { + console.warn('🛍️ ALL_PRODUCTS RESOLVER - ВЫЗВАН:', { + userId: context.user?.id, + search: args.search, + category: args.category, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const where: Record = { + isActive: true, // Показываем только активные товары + // Показываем и товары, и расходники поставщиков + organization: { + type: 'WHOLESALE', // Только товары поставщиков + }, + } + + if (args.search) { + where.OR = [ + { name: { contains: args.search, mode: 'insensitive' } }, + { article: { contains: args.search, mode: 'insensitive' } }, + { description: { contains: args.search, mode: 'insensitive' } }, + { brand: { contains: args.search, mode: 'insensitive' } }, + ] + } + + if (args.category) { + where.categoryId = args.category + } + + const products = await prisma.product.findMany({ + where, + include: { + category: true, + organization: { + include: { + users: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: 100, // Ограничиваем количество результатов + }) + + console.warn('🔥 ALL_PRODUCTS RESOLVER DEBUG:', { + searchArgs: args, + whereCondition: where, + totalProducts: products.length, + productTypes: products.map((p) => ({ + id: p.id, + name: p.name, + type: p.type, + org: p.organization.name, + })), + }) + + return products + }, + + // Товары конкретной организации (для формы создания поставки) + organizationProducts: async ( + _: unknown, + args: { organizationId: string; search?: string; category?: string; type?: string }, + context: Context, + ) => { + console.warn('🏢 ORGANIZATION_PRODUCTS RESOLVER - ВЫЗВАН:', { + userId: context.user?.id, + organizationId: args.organizationId, + search: args.search, + category: args.category, + type: args.type, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const where: Record = { + isActive: true, // Показываем только активные товары + organizationId: args.organizationId, // Фильтруем по конкретной организации + type: args.type || 'ТОВАР', // Показываем только товары по умолчанию, НЕ расходники согласно development-checklist.md + } + + if (args.search) { + where.OR = [ + { name: { contains: args.search, mode: 'insensitive' } }, + { article: { contains: args.search, mode: 'insensitive' } }, + { description: { contains: args.search, mode: 'insensitive' } }, + { brand: { contains: args.search, mode: 'insensitive' } }, + ] + } + + if (args.category) { + where.categoryId = args.category + } + + const products = await prisma.product.findMany({ + where, + include: { + category: true, + organization: { + include: { + users: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: 100, // Ограничиваем количество результатов + }) + + console.warn('🔥 ORGANIZATION_PRODUCTS RESOLVER DEBUG:', { + organizationId: args.organizationId, + searchArgs: args, + whereCondition: where, + totalProducts: products.length, + productTypes: products.map((p) => ({ + id: p.id, + name: p.name, + type: p.type, + isActive: p.isActive, + })), + }) + + return products + }, + }, + + Mutation: { + // Создать товар + createProduct: async ( + _: unknown, + args: { + input: { + name: string + article: string + description?: string + price: number + pricePerSet?: number + quantity: number + setQuantity?: number + ordered?: number + inTransit?: number + stock?: number + sold?: number + type?: 'PRODUCT' | 'CONSUMABLE' + categoryId?: string + brand?: string + color?: string + size?: string + weight?: number + dimensions?: string + material?: string + images?: string[] + mainImage?: string + isActive?: boolean + } + }, + context: Context, + ) => { + console.warn('🆕 CREATE_PRODUCT RESOLVER - ВЫЗВАН:', { + hasUser: !!context.user, + userId: context.user?.id, + inputData: args.input, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверяем, что это поставщик + if (currentUser.organization.type !== 'WHOLESALE') { + throw new GraphQLError('Товары доступны только для поставщиков') + } + + // Проверяем уникальность артикула в рамках организации + const existingProduct = await prisma.product.findFirst({ + where: { + article: args.input.article, + organizationId: currentUser.organization.id, + }, + }) + + if (existingProduct) { + return { + success: false, + message: 'Товар с таким артикулом уже существует', + } + } + + try { + console.warn('🛍️ СОЗДАНИЕ ТОВАРА - НАЧАЛО:', { + userId: currentUser.id, + organizationId: currentUser.organization.id, + organizationType: currentUser.organization.type, + productData: { + name: args.input.name, + article: args.input.article, + type: args.input.type || 'PRODUCT', + isActive: args.input.isActive ?? true, + }, + }) + + const product = await prisma.product.create({ + data: { + name: args.input.name, + article: args.input.article, + description: args.input.description, + price: args.input.price, + pricePerSet: args.input.pricePerSet, + quantity: args.input.quantity, + setQuantity: args.input.setQuantity, + ordered: args.input.ordered, + inTransit: args.input.inTransit, + stock: args.input.stock, + sold: args.input.sold, + type: args.input.type || 'PRODUCT', + categoryId: args.input.categoryId, + brand: args.input.brand, + color: args.input.color, + size: args.input.size, + weight: args.input.weight, + dimensions: args.input.dimensions, + material: args.input.material, + images: JSON.stringify(args.input.images || []), + mainImage: args.input.mainImage, + isActive: args.input.isActive ?? true, + organizationId: currentUser.organization.id, + }, + include: { + category: true, + organization: true, + }, + }) + + console.warn('✅ ТОВАР УСПЕШНО СОЗДАН:', { + productId: product.id, + name: product.name, + article: product.article, + type: product.type, + isActive: product.isActive, + organizationId: product.organizationId, + createdAt: product.createdAt, + }) + + return { + success: true, + message: 'Товар успешно создан', + product, + } + } catch (error) { + console.error('Error creating product:', error) + return { + success: false, + message: 'Ошибка при создании товара', + } + } + }, + + // Обновить товар + updateProduct: async ( + _: unknown, + args: { + id: string + input: { + name: string + article: string + description?: string + price: number + pricePerSet?: number + quantity: number + setQuantity?: number + ordered?: number + inTransit?: number + stock?: number + sold?: number + type?: 'PRODUCT' | 'CONSUMABLE' + categoryId?: string + brand?: string + color?: string + size?: string + weight?: number + dimensions?: string + material?: string + images?: string[] + mainImage?: string + isActive?: boolean + } + }, + context: Context, + ) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверяем, что товар принадлежит текущей организации + const existingProduct = await prisma.product.findFirst({ + where: { + id: args.id, + organizationId: currentUser.organization.id, + }, + }) + + if (!existingProduct) { + throw new GraphQLError('Товар не найден или нет доступа') + } + + // Проверяем уникальность артикула (если он изменился) + if (args.input.article !== existingProduct.article) { + const duplicateProduct = await prisma.product.findFirst({ + where: { + article: args.input.article, + organizationId: currentUser.organization.id, + NOT: { id: args.id }, + }, + }) + + if (duplicateProduct) { + return { + success: false, + message: 'Товар с таким артикулом уже существует', + } + } + } + + try { + const product = await prisma.product.update({ + where: { id: args.id }, + data: { + name: args.input.name, + article: args.input.article, + description: args.input.description, + price: args.input.price, + pricePerSet: args.input.pricePerSet, + quantity: args.input.quantity, + setQuantity: args.input.setQuantity, + ordered: args.input.ordered, + inTransit: args.input.inTransit, + stock: args.input.stock, + sold: args.input.sold, + ...(args.input.type && { type: args.input.type }), + categoryId: args.input.categoryId, + brand: args.input.brand, + color: args.input.color, + size: args.input.size, + weight: args.input.weight, + dimensions: args.input.dimensions, + material: args.input.material, + images: args.input.images ? JSON.stringify(args.input.images) : undefined, + mainImage: args.input.mainImage, + isActive: args.input.isActive ?? true, + }, + include: { + category: true, + organization: true, + }, + }) + + return { + success: true, + message: 'Товар успешно обновлен', + product, + } + } catch (error) { + console.error('Error updating product:', error) + return { + success: false, + message: 'Ошибка при обновлении товара', + } + } + }, + + // Проверка уникальности артикула + checkArticleUniqueness: async (_: unknown, args: { article: string; excludeId?: string }, context: Context) => { + const { currentUser, prisma } = context + + if (!currentUser?.organization?.id) { + return { + isUnique: false, + existingProduct: null, + } + } + + try { + const existingProduct = await prisma.product.findFirst({ + where: { + article: args.article, + organizationId: currentUser.organization.id, + ...(args.excludeId && { id: { not: args.excludeId } }), + }, + select: { + id: true, + name: true, + article: true, + }, + }) + + return { + isUnique: !existingProduct, + existingProduct, + } + } catch (error) { + console.error('Error checking article uniqueness:', error) + return { + isUnique: false, + existingProduct: null, + } + } + }, + + // Удаление товара + deleteProduct: async (_: unknown, args: { id: string }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверяем, что товар принадлежит текущей организации + const existingProduct = await prisma.product.findFirst({ + where: { + id: args.id, + organizationId: currentUser.organization.id, + }, + }) + + if (!existingProduct) { + throw new GraphQLError('Товар не найден или нет доступа') + } + + try { + await prisma.product.delete({ + where: { id: args.id }, + }) + + return true + } catch (error) { + console.error('Error deleting product:', error) + return false + } + }, + }, + + // Type resolvers для Product + Product: { + type: (parent: { type?: string | null }) => parent.type || 'PRODUCT', + images: (parent: { images: unknown }) => { + // Если images это строка JSON, парсим её в массив + if (typeof parent.images === 'string') { + try { + return JSON.parse(parent.images) + } catch { + return [] + } + } + // Если это уже массив, возвращаем как есть + if (Array.isArray(parent.images)) { + return parent.images + } + // Иначе возвращаем пустой массив + return [] + }, + }, +} \ No newline at end of file diff --git a/src/graphql/resolvers/domains/referrals.ts b/src/graphql/resolvers/domains/referrals.ts new file mode 100644 index 0000000..212b3d2 --- /dev/null +++ b/src/graphql/resolvers/domains/referrals.ts @@ -0,0 +1,221 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' + +// Referrals Domain Resolvers - управление реферальной системой (мигрировано из referrals.ts) + +// ============================================================================= +// 🔐 AUTHENTICATION HELPERS +// ============================================================================= + +const withAuth = (resolver: any) => { + return async (parent: any, args: any, context: Context) => { + console.log('🔐 REFERRALS DOMAIN AUTH CHECK:', { + hasUser: !!context.user, + userId: context.user?.id, + organizationId: context.user?.organizationId, + }) + if (!context.user) { + console.error('❌ AUTH FAILED: No user in context') + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + console.log('✅ AUTH PASSED: Calling resolver') + try { + const result = await resolver(parent, args, context) + console.log('🎯 RESOLVER RESULT TYPE:', typeof result, result === null ? 'NULL RESULT!' : 'Has result') + return result + } catch (error) { + console.error('💥 RESOLVER ERROR:', error) + throw error + } + } +} + +const checkOrganizationAccess = async (userId: string) => { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { organization: true }, + }) + + if (!user?.organizationId) { + throw new GraphQLError('Пользователь не привязан к организации', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + return user +} + +// ============================================================================= +// 🔗 REFERRALS DOMAIN RESOLVERS +// ============================================================================= + +export const referralResolvers: DomainResolvers = { + Query: { + // Получить реферальную ссылку текущего пользователя + myReferralLink: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_REFERRAL_LINK DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + const user = await checkOrganizationAccess(context.user!.id) + console.log('🔍 MY_REFERRAL_LINK - USER DATA:', { + userId: user.id, + organizationId: user.organizationId, + hasOrganization: !!user.organization + }) + + const organization = await prisma.organization.findUnique({ + where: { id: user.organizationId! }, + select: { + id: true, + referralCode: true, + inn: true, + type: true, + name: true + }, + }) + + console.log('🔍 MY_REFERRAL_LINK - ORGANIZATION DATA:', { + organizationFound: !!organization, + organizationId: organization?.id, + referralCode: organization?.referralCode, + inn: organization?.inn, + type: organization?.type, + name: organization?.name + }) + + if (!organization?.referralCode) { + console.error('❌ MY_REFERRAL_LINK - MISSING REFERRAL CODE:', { + organization: organization, + hasOrganization: !!organization, + referralCodeExists: !!organization?.referralCode + }) + throw new GraphQLError('Реферальный код не найден') + } + + const link = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/register?ref=${organization.referralCode}` + + console.log('✅ MY_REFERRAL_LINK DOMAIN SUCCESS:', { link }) + return link || 'http://localhost:3000/register?ref=ERROR' + } catch (error) { + console.error('❌ MY_REFERRAL_LINK DOMAIN ERROR:', error) + throw error + } + }), + + // Получить партнерскую ссылку текущего пользователя + myPartnerLink: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_PARTNER_LINK DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + const user = await checkOrganizationAccess(context.user!.id) + + const organization = await prisma.organization.findUnique({ + where: { id: user.organizationId! }, + select: { referralCode: true }, + }) + + if (!organization?.referralCode) { + throw new GraphQLError('Реферальный код не найден') + } + + const link = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/register?partner=${organization.referralCode}` + + console.log('✅ MY_PARTNER_LINK DOMAIN SUCCESS:', { link }) + return link + } catch (error) { + console.error('❌ MY_PARTNER_LINK DOMAIN ERROR:', error) + throw error + } + }), + + // Получить статистику по рефералам + myReferralStats: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_REFERRAL_STATS DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + await checkOrganizationAccess(context.user!.id) + + // TODO: Реализовать настоящую статистику рефералов + // Исправленная заглушка соответствующая GraphQL схеме + const result = { + totalPartners: 0, + totalSpheres: 0, + monthlyPartners: 0, + monthlySpheres: 0, + referralsByType: [ + { type: 'SELLER', count: 0, spheres: 0 }, + { type: 'WHOLESALE', count: 0, spheres: 0 }, + { type: 'FULFILLMENT', count: 0, spheres: 0 }, + { type: 'LOGIST', count: 0, spheres: 0 }, + ], + referralsBySource: [ + { source: 'REFERRAL_LINK', count: 0, spheres: 0 }, + { source: 'AUTO_BUSINESS', count: 0, spheres: 0 }, + ], + } + + console.log('✅ MY_REFERRAL_STATS DOMAIN SUCCESS:', { result }) + return result + } catch (error) { + console.error('❌ MY_REFERRAL_STATS DOMAIN ERROR:', error) + throw error + } + }), + + // Получить список рефералов + myReferrals: withAuth(async (_: unknown, _args: unknown, context: Context) => { + console.log('🔍 MY_REFERRALS DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + await checkOrganizationAccess(context.user!.id) + + // TODO: Реализовать настоящий список рефералов + // Временная заглушка для отладки + const result = { + referrals: [], + totalCount: 0, + totalPages: 0, + } + + console.log('✅ MY_REFERRALS DOMAIN SUCCESS:', { result }) + return result + } catch (error) { + console.error('❌ MY_REFERRALS DOMAIN ERROR:', error) + return { + referrals: [], + totalCount: 0, + totalPages: 0, + } + } + }), + + // Получить историю транзакций рефералов + myReferralTransactions: withAuth(async (_: unknown, args: { limit?: number; offset?: number }, context: Context) => { + console.log('🔍 MY_REFERRAL_TRANSACTIONS DOMAIN QUERY STARTED:', { userId: context.user?.id, args }) + try { + await checkOrganizationAccess(context.user!.id) + + // TODO: Реализовать настоящую историю транзакций рефералов + // Временная заглушка для отладки + const result = { + transactions: [], + totalCount: 0, + } + + console.log('✅ MY_REFERRAL_TRANSACTIONS DOMAIN SUCCESS:', { result }) + return result + } catch (error) { + console.error('❌ MY_REFERRAL_TRANSACTIONS DOMAIN ERROR:', error) + return { + transactions: [], + totalCount: 0, + } + } + }), + }, + + Mutation: { + // TODO: Добавить мутации для реферальной системы при необходимости + }, +} \ No newline at end of file diff --git a/src/graphql/resolvers/domains/seller-consumables.ts b/src/graphql/resolvers/domains/seller-consumables.ts new file mode 100644 index 0000000..15a0815 --- /dev/null +++ b/src/graphql/resolvers/domains/seller-consumables.ts @@ -0,0 +1,593 @@ +import { GraphQLError } from 'graphql' + +import { processSellerConsumableSupplyReceipt } from '../../../lib/inventory-management' +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { notifyOrganization } from '../../../lib/realtime' +import { DomainResolvers } from '../shared/types' + +// Seller Consumables Domain Resolvers - система поставок расходников селлера + +// ============================================================================= +// 🔐 AUTHENTICATION HELPERS +// ============================================================================= + +const withAuth = (resolver: any) => { + return async (parent: any, args: any, context: Context) => { + console.log('🔐 SELLER CONSUMABLES DOMAIN AUTH CHECK:', { + hasUser: !!context.user, + userId: context.user?.id, + organizationId: context.user?.organizationId, + }) + if (!context.user) { + console.error('❌ AUTH FAILED: No user in context') + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + console.log('✅ AUTH PASSED: Calling resolver') + try { + const result = await resolver(parent, args, context) + console.log('🎯 RESOLVER RESULT TYPE:', typeof result, result === null ? 'NULL RESULT!' : 'Has result') + return result + } catch (error) { + console.error('💥 RESOLVER ERROR:', error) + throw error + } + } +} + +const checkOrganizationAccess = async (userId: string) => { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { organization: true }, + }) + + if (!user?.organizationId) { + throw new GraphQLError('Пользователь не привязан к организации', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + return user +} + +// ============================================================================= +// 📦 SELLER CONSUMABLES DOMAIN RESOLVERS +// ============================================================================= + +export const sellerConsumablesResolvers: DomainResolvers = { + Query: { + // Мои поставки (для селлеров - заказы которые я создал) + mySellerConsumableSupplies: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_SELLER_CONSUMABLE_SUPPLIES DOMAIN QUERY STARTED:', { userId: context.user?.id }) + + try { + const user = await checkOrganizationAccess(context.user!.id) + + if (user.organization?.type !== 'SELLER') { + console.log('✅ Not a seller, returning empty array') + return [] // Возвращаем пустой массив если пользователь не селлер + } + + const supplies = await prisma.sellerConsumableSupplyOrder.findMany({ + where: { + sellerId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + items: { + include: { + product: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + console.log('✅ MY_SELLER_CONSUMABLE_SUPPLIES DOMAIN SUCCESS:', { count: supplies.length }) + return supplies + } catch (error) { + console.error('❌ MY_SELLER_CONSUMABLE_SUPPLIES DOMAIN ERROR:', error) + return [] + } + }), + + // Входящие поставки (для фулфилмента - заказы селлеров для нас) + incomingSellerSupplies: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 INCOMING_SELLER_SUPPLIES DOMAIN QUERY STARTED:', { userId: context.user?.id }) + + try { + const user = await checkOrganizationAccess(context.user!.id) + + const supplies = await prisma.sellerConsumableSupplyOrder.findMany({ + where: { + fulfillmentCenterId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + items: { + include: { + product: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + console.log('✅ INCOMING_SELLER_SUPPLIES DOMAIN SUCCESS:', { count: supplies.length }) + return supplies + } catch (error) { + console.error('❌ INCOMING_SELLER_SUPPLIES DOMAIN ERROR:', error) + return [] + } + }), + + // Мои запросы поставок (для поставщиков - заказы которые адресованы нам) + mySellerSupplyRequests: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_SELLER_SUPPLY_REQUESTS DOMAIN QUERY STARTED:', { userId: context.user?.id }) + + try { + const user = await checkOrganizationAccess(context.user!.id) + + const supplies = await prisma.sellerConsumableSupplyOrder.findMany({ + where: { + supplierId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + items: { + include: { + product: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + console.log('✅ MY_SELLER_SUPPLY_REQUESTS DOMAIN SUCCESS:', { count: supplies.length }) + return supplies + } catch (error) { + console.error('❌ MY_SELLER_SUPPLY_REQUESTS DOMAIN ERROR:', error) + return [] + } + }), + + // Детали конкретной поставки селлерских расходников + sellerConsumableSupply: withAuth(async (_: unknown, args: { id: string }, context: Context) => { + console.log('🔍 SELLER_CONSUMABLE_SUPPLY DOMAIN QUERY STARTED:', { + userId: context.user?.id, + supplyId: args.id, + }) + + try { + const user = await checkOrganizationAccess(context.user!.id) + + const supply = await prisma.sellerConsumableSupplyOrder.findFirst({ + where: { id: args.id }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + console.log('✅ SELLER_CONSUMABLE_SUPPLY DOMAIN SUCCESS:', { + found: !!supply, + supplyId: supply?.id, + }) + return supply + } catch (error) { + console.error('❌ SELLER_CONSUMABLE_SUPPLY DOMAIN ERROR:', error) + return null + } + }), + }, + + Mutation: { + // Создание поставки расходников селлера + createSellerConsumableSupply: withAuth(async (_: unknown, args: { input: any }, context: Context) => { + console.log('🔍 CREATE_SELLER_CONSUMABLE_SUPPLY DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + inputKeys: Object.keys(args.input), + }) + + try { + const user = await checkOrganizationAccess(context.user!.id) + + const { fulfillmentCenterId, supplierId, requestedDeliveryDate, items, logisticsPartnerId, notes } = args.input + + // Проверяем фулфилмент-центр + const fulfillmentCenter = await prisma.organization.findFirst({ + where: { id: fulfillmentCenterId }, + include: { + counterpartyOf: { + where: { organizationId: user.organizationId! }, + }, + }, + }) + + if (!fulfillmentCenter || fulfillmentCenter.counterpartyOf.length === 0) { + throw new GraphQLError('Фулфилмент-центр недоступен') + } + + // Проверяем поставщика + const supplier = await prisma.organization.findFirst({ + where: { id: supplierId }, + include: { + counterpartyOf: { + where: { organizationId: user.organizationId! }, + }, + }, + }) + + if (!supplier || supplier.counterpartyOf.length === 0) { + throw new GraphQLError('Поставщик недоступен') + } + + // Подготавливаем товары и подсчитываем общую стоимость + const orderItems = [] + let totalAmount = 0 + + for (const item of items) { + const product = await prisma.product.findUnique({ + where: { id: item.productId }, + }) + + if (!product) { + throw new GraphQLError(`Товар с ID ${item.productId} не найден`) + } + + const itemTotalPrice = parseFloat(product.price.toString()) * item.requestedQuantity + totalAmount += itemTotalPrice + + orderItems.push({ + productId: item.productId, + requestedQuantity: item.requestedQuantity, + unitPrice: product.price, + totalPrice: itemTotalPrice, + }) + } + + // Создаем заказ + const supplyOrder = await prisma.sellerConsumableSupplyOrder.create({ + data: { + sellerId: user.organizationId!, + fulfillmentCenterId, + supplierId, + logisticsPartnerId, + requestedDeliveryDate: new Date(requestedDeliveryDate), + status: 'PENDING', + notes, + totalCostWithDelivery: totalAmount, + items: { + create: orderItems, + }, + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + // Резервируем товары у поставщика + for (const item of orderItems) { + await prisma.product.update({ + where: { id: item.productId }, + data: { + ordered: { + increment: item.requestedQuantity, + }, + }, + }) + } + + // Отправляем уведомления + await notifyOrganization( + supplierId, + `Новый заказ от ${user.organization.name}`, + 'NEW_SUPPLY_ORDER', + { orderId: supplyOrder.id }, + ) + + console.log('✅ CREATE_SELLER_CONSUMABLE_SUPPLY DOMAIN SUCCESS:', { + supplyOrderId: supplyOrder.id, + totalAmount, + itemsCount: orderItems.length, + }) + + return { + success: true, + message: 'Поставка успешно создана', + supplyOrder: await prisma.sellerConsumableSupplyOrder.findUnique({ + where: { id: supplyOrder.id }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + items: { + include: { + product: true, + }, + }, + }, + }), + } + } catch (error) { + console.error('❌ CREATE_SELLER_CONSUMABLE_SUPPLY DOMAIN ERROR:', error) + + if (error instanceof GraphQLError) { + throw error + } + + throw new GraphQLError('Ошибка создания поставки') + } + }), + + // Обновление статуса поставки + updateSellerSupplyStatus: withAuth(async ( + _: unknown, + args: { id: string; status: string; notes?: string }, + context: Context, + ) => { + console.log('🔍 UPDATE_SELLER_SUPPLY_STATUS DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + supplyId: args.id, + newStatus: args.status, + }) + + try { + const user = await checkOrganizationAccess(context.user!.id) + + const supply = await prisma.sellerConsumableSupplyOrder.findFirst({ + where: { id: args.id }, + include: { + seller: true, + supplier: true, + fulfillmentCenter: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + if (!supply) { + throw new GraphQLError('Поставка не найдена') + } + + const updateData: any = { + status: args.status, + updatedAt: new Date(), + } + + if (args.notes) { + updateData.notes = args.notes + } + + if (args.status === 'RECEIVED') { + updateData.receivedAt = new Date() + updateData.receivedById = user.id + } + + const updatedSupply = await prisma.sellerConsumableSupplyOrder.update({ + where: { id: args.id }, + data: updateData, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + // Если поставка получена, добавляем товары на склад + if (args.status === 'RECEIVED') { + await processSellerConsumableSupplyReceipt(supply.id) + + for (const item of supply.items) { + // V2: Создаем запись в SellerConsumableInventory вместо Supply + await prisma.sellerConsumableInventory.upsert({ + where: { + sellerId_fulfillmentCenterId_productId: { + sellerId: supply.sellerId, + fulfillmentCenterId: supply.fulfillmentCenterId, + productId: item.productId, + } + }, + update: { + // Увеличиваем остаток при повторной поставке + currentStock: { + increment: item.receivedQuantity || item.requestedQuantity, + }, + totalReceived: { + increment: item.receivedQuantity || item.requestedQuantity, + }, + lastSupplyDate: new Date(), + updatedAt: new Date(), + }, + create: { + sellerId: supply.sellerId, + fulfillmentCenterId: supply.fulfillmentCenterId, + productId: item.productId, + currentStock: item.receivedQuantity || item.requestedQuantity, + minStock: 0, // TODO: настраивается селлером + totalReceived: item.receivedQuantity || item.requestedQuantity, + totalUsed: 0, + reservedStock: 0, + lastSupplyDate: new Date(), + notes: `Поступление от поставки ${supply.id}`, + // Связи создаются автоматически через ID + }, + }) + + console.log('✅ V2 MIGRATION: Created SellerConsumableInventory record', { + sellerId: supply.sellerId, + fulfillmentCenterId: supply.fulfillmentCenterId, + productId: item.productId, + quantity: item.receivedQuantity || item.requestedQuantity, + }) + } + } + + console.log('✅ UPDATE_SELLER_SUPPLY_STATUS DOMAIN SUCCESS:', { + supplyId: updatedSupply.id, + oldStatus: supply.status, + newStatus: args.status, + }) + + return updatedSupply + } catch (error) { + console.error('❌ UPDATE_SELLER_SUPPLY_STATUS DOMAIN ERROR:', error) + + if (error instanceof GraphQLError) { + throw error + } + + throw new GraphQLError('Ошибка обновления статуса поставки') + } + }), + + // Отмена поставки + cancelSellerSupply: withAuth(async (_: unknown, args: { id: string }, context: Context) => { + console.log('🔍 CANCEL_SELLER_SUPPLY DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + supplyId: args.id, + }) + + try { + const user = await checkOrganizationAccess(context.user!.id) + + const supply = await prisma.sellerConsumableSupplyOrder.findFirst({ + where: { id: args.id }, + include: { + seller: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + if (!supply) { + throw new GraphQLError('Поставка не найдена') + } + + if (!['PENDING', 'CONFIRMED'].includes(supply.status)) { + throw new GraphQLError('Поставку в данном статусе нельзя отменить') + } + + // Отменяем заказ в транзакции + const cancelledSupply = await prisma.$transaction(async (tx) => { + const updated = await tx.sellerConsumableSupplyOrder.update({ + where: { id: args.id }, + data: { + status: 'CANCELLED', + updatedAt: new Date(), + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + // Освобождаем зарезервированные товары у поставщика + for (const item of supply.items) { + await tx.product.update({ + where: { id: item.productId }, + data: { + ordered: { + decrement: item.requestedQuantity, + }, + }, + }) + } + + return updated + }) + + // Уведомления об отмене + if (supply.supplierId) { + await notifyOrganization( + supply.supplierId, + `Селлер ${user.organization.name} отменил заказ`, + 'SUPPLY_CANCELLED', + { orderId: args.id }, + ) + } + + await notifyOrganization( + supply.fulfillmentCenterId, + `Селлер ${user.organization.name} отменил поставку`, + 'SUPPLY_CANCELLED', + { orderId: args.id }, + ) + + console.log('✅ CANCEL_SELLER_SUPPLY DOMAIN SUCCESS:', { + supplyId: cancelledSupply.id, + previousStatus: supply.status, + }) + + return cancelledSupply + } catch (error) { + console.error('❌ CANCEL_SELLER_SUPPLY DOMAIN ERROR:', error) + + if (error instanceof GraphQLError) { + throw error + } + + throw new GraphQLError('Ошибка отмены поставки') + } + }), + }, +} + +console.warn('🔥 SELLER CONSUMABLES DOMAIN МОДУЛЬ ЭКСПОРТЫ ГОТОВЫ') \ No newline at end of file diff --git a/src/graphql/resolvers/domains/seller-goods.ts b/src/graphql/resolvers/domains/seller-goods.ts new file mode 100644 index 0000000..ee3b030 --- /dev/null +++ b/src/graphql/resolvers/domains/seller-goods.ts @@ -0,0 +1,785 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { processSellerGoodsSupplyReceipt } from '../../../lib/inventory-management-goods' +import { notifyOrganization } from '../../../lib/realtime' +import { DomainResolvers } from '../shared/types' + +// Seller Goods Domain Resolvers - управление товарными поставками селлеров (мигрировано из goods-supply-v2.ts) + +// ============================================================================= +// 🔐 AUTHENTICATION HELPERS +// ============================================================================= + +const withAuth = (resolver: any) => { + return async (parent: any, args: any, context: Context) => { + console.log('🔐 SELLER GOODS DOMAIN AUTH CHECK:', { + hasUser: !!context.user, + userId: context.user?.id, + organizationId: context.user?.organizationId, + }) + if (!context.user) { + console.error('❌ AUTH FAILED: No user in context') + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + console.log('✅ AUTH PASSED: Calling resolver') + try { + const result = await resolver(parent, args, context) + console.log('🎯 RESOLVER RESULT TYPE:', typeof result, result === null ? 'NULL RESULT!' : 'Has result') + return result + } catch (error) { + console.error('💥 RESOLVER ERROR:', error) + throw error + } + } +} + +const checkSellerAccess = 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 !== 'SELLER') { + throw new GraphQLError('Доступно только для селлеров', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + return user +} + +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 +} + +const checkWholesaleAccess = 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 !== 'WHOLESALE') { + throw new GraphQLError('Доступно только для поставщиков', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + return user +} + +// ============================================================================= +// 🛒 SELLER GOODS DOMAIN RESOLVERS +// ============================================================================= + +export const sellerGoodsResolvers: DomainResolvers = { + Query: { + // Мои товарные поставки (для селлеров - заказы которые я создал) + mySellerGoodsSupplies: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_SELLER_GOODS_SUPPLIES DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + const user = await checkSellerAccess(context.user!.id) + + const supplies = await prisma.sellerGoodsSupplyOrder.findMany({ + where: { + sellerId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + recipeItems: { + include: { + product: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + console.log('✅ MY_SELLER_GOODS_SUPPLIES DOMAIN SUCCESS:', { count: supplies.length }) + return supplies + } catch (error) { + console.error('❌ MY_SELLER_GOODS_SUPPLIES DOMAIN ERROR:', error) + return [] + } + }), + + // Входящие товарные заказы от селлеров (для фулфилмента) + incomingSellerGoodsSupplies: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 INCOMING_SELLER_GOODS_SUPPLIES DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + const supplies = await prisma.sellerGoodsSupplyOrder.findMany({ + where: { + fulfillmentCenterId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + recipeItems: { + include: { + product: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + console.log('✅ INCOMING_SELLER_GOODS_SUPPLIES DOMAIN SUCCESS:', { count: supplies.length }) + return supplies + } catch (error) { + console.error('❌ INCOMING_SELLER_GOODS_SUPPLIES DOMAIN ERROR:', error) + return [] + } + }), + + // Товарные заказы от селлеров (для поставщиков) + mySellerGoodsSupplyRequests: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_SELLER_GOODS_SUPPLY_REQUESTS DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + const user = await checkWholesaleAccess(context.user!.id) + + const supplies = await prisma.sellerGoodsSupplyOrder.findMany({ + where: { + supplierId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + recipeItems: { + include: { + product: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + console.log('✅ MY_SELLER_GOODS_SUPPLY_REQUESTS DOMAIN SUCCESS:', { count: supplies.length }) + return supplies + } catch (error) { + console.error('❌ MY_SELLER_GOODS_SUPPLY_REQUESTS DOMAIN ERROR:', error) + return [] + } + }), + + // Получение конкретной товарной поставки селлера + sellerGoodsSupply: withAuth(async (_: unknown, args: { id: string }, context: Context) => { + console.log('🔍 SELLER_GOODS_SUPPLY DOMAIN QUERY STARTED:', { + userId: context.user?.id, + supplyId: args.id + }) + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Организация не найдена') + } + + const supply = await prisma.sellerGoodsSupplyOrder.findUnique({ + where: { id: args.id }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + recipeItems: { + include: { + product: true, + }, + }, + }, + }) + + if (!supply) { + throw new GraphQLError('Поставка не найдена') + } + + // Проверка доступа + const hasAccess = + (user.organization.type === 'SELLER' && supply.sellerId === user.organizationId) || + (user.organization.type === 'FULFILLMENT' && supply.fulfillmentCenterId === user.organizationId) || + (user.organization.type === 'WHOLESALE' && supply.supplierId === user.organizationId) || + (user.organization.type === 'LOGIST' && supply.logisticsPartnerId === user.organizationId) + + if (!hasAccess) { + throw new GraphQLError('Нет доступа к этой поставке') + } + + console.log('✅ SELLER_GOODS_SUPPLY DOMAIN SUCCESS:', { supplyId: supply.id }) + return supply + } catch (error) { + console.error('❌ SELLER_GOODS_SUPPLY DOMAIN ERROR:', error) + throw error + } + }), + + // Инвентарь товаров селлера на складе фулфилмента + mySellerGoodsInventory: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_SELLER_GOODS_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) { + return [] + } + + let inventoryItems + + if (user.organization.type === 'SELLER') { + // Селлер видит свои товары на всех складах + inventoryItems = await prisma.sellerGoodsInventory.findMany({ + where: { + sellerId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + product: true, + }, + orderBy: { + lastSupplyDate: 'desc', + }, + }) + } else if (user.organization.type === 'FULFILLMENT') { + // Фулфилмент видит все товары на своем складе + inventoryItems = await prisma.sellerGoodsInventory.findMany({ + where: { + fulfillmentCenterId: user.organizationId!, + }, + include: { + seller: true, + fulfillmentCenter: true, + product: true, + }, + orderBy: { + lastSupplyDate: 'desc', + }, + }) + } else { + return [] + } + + console.log('✅ MY_SELLER_GOODS_INVENTORY DOMAIN SUCCESS:', { count: inventoryItems.length }) + return inventoryItems + } catch (error) { + console.error('❌ MY_SELLER_GOODS_INVENTORY DOMAIN ERROR:', error) + return [] + } + }), + }, + + Mutation: { + // Создание поставки товаров селлера + createSellerGoodsSupply: withAuth(async (_: unknown, args: { input: any }, context: Context) => { + console.log('🔍 CREATE_SELLER_GOODS_SUPPLY DOMAIN MUTATION STARTED:', { userId: context.user?.id }) + try { + const user = await checkSellerAccess(context.user!.id) + + const { fulfillmentCenterId, supplierId, logisticsPartnerId, requestedDeliveryDate, notes, recipeItems } = args.input + + // 🔍 ВАЛИДАЦИЯ ПАРТНЕРОВ + const fulfillmentCenter = await prisma.organization.findUnique({ + where: { id: fulfillmentCenterId }, + include: { + counterpartyOf: { + where: { organizationId: user.organizationId! }, + }, + }, + }) + + if (!fulfillmentCenter || fulfillmentCenter.type !== 'FULFILLMENT') { + throw new GraphQLError('Фулфилмент-центр не найден или имеет неверный тип') + } + + if (fulfillmentCenter.counterpartyOf.length === 0) { + throw new GraphQLError('Нет партнерских отношений с данным фулфилмент-центром') + } + + const supplier = await prisma.organization.findUnique({ + where: { id: supplierId }, + include: { + counterpartyOf: { + where: { organizationId: user.organizationId! }, + }, + }, + }) + + if (!supplier || supplier.type !== 'WHOLESALE') { + throw new GraphQLError('Поставщик не найден или имеет неверный тип') + } + + if (supplier.counterpartyOf.length === 0) { + throw new GraphQLError('Нет партнерских отношений с данным поставщиком') + } + + // 🔍 ВАЛИДАЦИЯ ТОВАРОВ И ОСТАТКОВ + let totalCost = 0 + const mainProducts = recipeItems.filter((item: any) => item.recipeType === 'MAIN_PRODUCT') + + if (mainProducts.length === 0) { + throw new GraphQLError('Должен быть хотя бы один основной товар') + } + + // Проверяем только основные товары (MAIN_PRODUCT) в рецептуре + for (const item of recipeItems) { + // В V2 временно валидируем только основные товары + if (item.recipeType !== 'MAIN_PRODUCT') { + console.log(`⚠️ Пропускаем валидацию ${item.recipeType} товара ${item.productId} - не поддерживается в V2`) + continue + } + + const product = await prisma.product.findUnique({ + where: { id: item.productId }, + }) + + if (!product) { + throw new GraphQLError(`Товар с ID ${item.productId} не найден`) + } + + if (product.organizationId !== supplierId) { + throw new GraphQLError(`Товар ${product.name} не принадлежит выбранному поставщику`) + } + + // Проверяем остатки основных товаров + const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0) + + if (item.quantity > availableStock) { + throw new GraphQLError( + `Недостаточно остатков товара "${product.name}". ` + + `Доступно: ${availableStock} шт., запрашивается: ${item.quantity} шт.`, + ) + } + + totalCost += product.price.toNumber() * item.quantity + } + + // 🚀 СОЗДАНИЕ ПОСТАВКИ В ТРАНЗАКЦИИ + const supplyOrder = await prisma.$transaction(async (tx) => { + // Создаем заказ поставки + const newOrder = await tx.sellerGoodsSupplyOrder.create({ + data: { + sellerId: user.organizationId!, + fulfillmentCenterId, + supplierId, + logisticsPartnerId, + requestedDeliveryDate: new Date(requestedDeliveryDate), + notes, + status: 'PENDING', + totalCostWithDelivery: totalCost, + }, + }) + + // Создаем записи рецептуры только для MAIN_PRODUCT + for (const item of recipeItems) { + // В V2 временно создаем только основные товары + if (item.recipeType !== 'MAIN_PRODUCT') { + console.log(`⚠️ Пропускаем создание записи для ${item.recipeType} товара ${item.productId}`) + continue + } + + await tx.goodsSupplyRecipeItem.create({ + data: { + supplyOrderId: newOrder.id, + productId: item.productId, + quantity: item.quantity, + recipeType: item.recipeType, + }, + }) + + // Резервируем основные товары у поставщика + await tx.product.update({ + where: { id: item.productId }, + data: { + ordered: { + increment: item.quantity, + }, + }, + }) + } + + return newOrder + }) + + // 📨 УВЕДОМЛЕНИЯ + await notifyOrganization( + supplierId, + `Новый заказ товаров от селлера ${user.organization!.name}`, + 'GOODS_SUPPLY_ORDER_CREATED', + { orderId: supplyOrder.id }, + ) + + await notifyOrganization( + fulfillmentCenterId, + `Селлер ${user.organization!.name} оформил поставку товаров на ваш склад`, + 'INCOMING_GOODS_SUPPLY_ORDER', + { orderId: supplyOrder.id }, + ) + + // Получаем созданную поставку с полными данными + const createdSupply = await prisma.sellerGoodsSupplyOrder.findUnique({ + where: { id: supplyOrder.id }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + recipeItems: { + include: { + product: true, + }, + }, + }, + }) + + const result = { + success: true, + message: 'Поставка товаров успешно создана', + supplyOrder: createdSupply, + } + console.log('✅ CREATE_SELLER_GOODS_SUPPLY DOMAIN SUCCESS:', { supplyOrderId: supplyOrder.id }) + return result + } catch (error: any) { + console.error('❌ CREATE_SELLER_GOODS_SUPPLY DOMAIN ERROR:', error) + + if (error instanceof GraphQLError) { + throw error + } + + throw new GraphQLError('Ошибка создания товарной поставки') + } + }), + + // Обновление статуса товарной поставки + updateSellerGoodsSupplyStatus: withAuth(async ( + _: unknown, + args: { id: string; status: string; notes?: string }, + context: Context, + ) => { + console.log('🔍 UPDATE_SELLER_GOODS_SUPPLY_STATUS DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + supplyId: args.id, + status: args.status + }) + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Организация не найдена') + } + + const supply = await prisma.sellerGoodsSupplyOrder.findUnique({ + where: { id: args.id }, + include: { + seller: true, + supplier: true, + fulfillmentCenter: true, + recipeItems: { + include: { + product: true, + }, + }, + }, + }) + + if (!supply) { + throw new GraphQLError('Поставка не найдена') + } + + // 🔐 ПРОВЕРКА ПРАВ И ЛОГИКИ ПЕРЕХОДОВ СТАТУСОВ + const { status } = args + const currentStatus = supply.status + const orgType = user.organization.type + + // Только поставщики могут переводить PENDING → APPROVED + if (status === 'APPROVED' && currentStatus === 'PENDING') { + if (orgType !== 'WHOLESALE' || supply.supplierId !== user.organizationId) { + throw new GraphQLError('Только поставщик может одобрить заказ') + } + } + // Только поставщики могут переводить APPROVED → SHIPPED + else if (status === 'SHIPPED' && currentStatus === 'APPROVED') { + if (orgType !== 'WHOLESALE' || supply.supplierId !== user.organizationId) { + throw new GraphQLError('Только поставщик может отметить отгрузку') + } + } + // Только фулфилмент может переводить SHIPPED → DELIVERED + else if (status === 'DELIVERED' && currentStatus === 'SHIPPED') { + if (orgType !== 'FULFILLMENT' || supply.fulfillmentCenterId !== user.organizationId) { + throw new GraphQLError('Только фулфилмент-центр может подтвердить получение') + } + } + // Только фулфилмент может переводить DELIVERED → COMPLETED + else if (status === 'COMPLETED' && currentStatus === 'DELIVERED') { + if (orgType !== 'FULFILLMENT' || supply.fulfillmentCenterId !== user.organizationId) { + throw new GraphQLError('Только фулфилмент-центр может завершить поставку') + } + } else { + throw new GraphQLError('Недопустимый переход статуса') + } + + // 📅 ОБНОВЛЕНИЕ ВРЕМЕННЫХ МЕТОК + const updateData: any = { + status, + updatedAt: new Date(), + } + + if (status === 'APPROVED' && orgType === 'WHOLESALE') { + updateData.supplierApprovedAt = new Date() + updateData.supplierNotes = args.notes + } + + if (status === 'SHIPPED' && orgType === 'WHOLESALE') { + updateData.shippedAt = new Date() + } + + if (status === 'DELIVERED' && orgType === 'FULFILLMENT') { + updateData.deliveredAt = new Date() + updateData.receivedById = user.id + updateData.receiptNotes = args.notes + } + + // 🔄 ОБНОВЛЕНИЕ В БАЗЕ + const updatedSupply = await prisma.sellerGoodsSupplyOrder.update({ + where: { id: args.id }, + data: updateData, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + logisticsPartner: true, + receivedBy: true, + recipeItems: { + include: { + product: true, + }, + }, + }, + }) + + // 📨 УВЕДОМЛЕНИЯ О СМЕНЕ СТАТУСА + if (status === 'APPROVED') { + await notifyOrganization( + supply.sellerId, + `Поставка товаров одобрена поставщиком ${user.organization.name}`, + 'GOODS_SUPPLY_APPROVED', + { orderId: args.id }, + ) + } + + if (status === 'SHIPPED') { + await notifyOrganization( + supply.sellerId, + `Поставка товаров отгружена поставщиком ${user.organization.name}`, + 'GOODS_SUPPLY_SHIPPED', + { orderId: args.id }, + ) + + await notifyOrganization( + supply.fulfillmentCenterId, + 'Поставка товаров в пути. Ожидается доставка', + 'GOODS_SUPPLY_IN_TRANSIT', + { orderId: args.id }, + ) + } + + if (status === 'DELIVERED') { + // 📦 АВТОМАТИЧЕСКОЕ СОЗДАНИЕ/ОБНОВЛЕНИЕ ИНВЕНТАРЯ V2 + await processSellerGoodsSupplyReceipt(args.id) + + await notifyOrganization( + supply.sellerId, + `Поставка товаров доставлена в ${supply.fulfillmentCenter.name}`, + 'GOODS_SUPPLY_DELIVERED', + { orderId: args.id }, + ) + } + + if (status === 'COMPLETED') { + await notifyOrganization( + supply.sellerId, + `Поставка товаров завершена. Товары размещены на складе ${supply.fulfillmentCenter.name}`, + 'GOODS_SUPPLY_COMPLETED', + { orderId: args.id }, + ) + } + + console.log('✅ UPDATE_SELLER_GOODS_SUPPLY_STATUS DOMAIN SUCCESS:', { + supplyId: updatedSupply.id, + newStatus: status + }) + return updatedSupply + } catch (error: any) { + console.error('❌ UPDATE_SELLER_GOODS_SUPPLY_STATUS DOMAIN ERROR:', error) + + if (error instanceof GraphQLError) { + throw error + } + + throw new GraphQLError('Ошибка обновления статуса товарной поставки') + } + }), + + // Отмена товарной поставки селлером + cancelSellerGoodsSupply: withAuth(async (_: unknown, args: { id: string }, context: Context) => { + console.log('🔍 CANCEL_SELLER_GOODS_SUPPLY DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + supplyId: args.id + }) + try { + const user = await checkSellerAccess(context.user!.id) + + const supply = await prisma.sellerGoodsSupplyOrder.findUnique({ + where: { id: args.id }, + include: { + seller: true, + recipeItems: { + include: { + product: true, + }, + }, + }, + }) + + if (!supply) { + throw new GraphQLError('Поставка не найдена') + } + + if (supply.sellerId !== user.organizationId) { + throw new GraphQLError('Вы можете отменить только свои поставки') + } + + // ✅ ПРОВЕРКА ВОЗМОЖНОСТИ ОТМЕНЫ (только PENDING и APPROVED) + if (!['PENDING', 'APPROVED'].includes(supply.status)) { + throw new GraphQLError('Поставку можно отменить только в статусе PENDING или APPROVED') + } + + // 🔄 ОТМЕНА В ТРАНЗАКЦИИ + const cancelledSupply = await prisma.$transaction(async (tx) => { + // Обновляем статус + const updated = await tx.sellerGoodsSupplyOrder.update({ + where: { id: args.id }, + data: { + status: 'CANCELLED', + updatedAt: new Date(), + }, + include: { + seller: true, + fulfillmentCenter: true, + supplier: true, + recipeItems: { + include: { + product: true, + }, + }, + }, + }) + + // Освобождаем зарезервированные товары у поставщика (только MAIN_PRODUCT) + for (const item of supply.recipeItems) { + if (item.recipeType === 'MAIN_PRODUCT') { + await tx.product.update({ + where: { id: item.productId }, + data: { + ordered: { + decrement: item.quantity, + }, + }, + }) + } + } + + return updated + }) + + // 📨 УВЕДОМЛЕНИЯ ОБ ОТМЕНЕ + if (supply.supplierId) { + await notifyOrganization( + supply.supplierId, + `Селлер ${user.organization!.name} отменил заказ товаров`, + 'GOODS_SUPPLY_CANCELLED', + { orderId: args.id }, + ) + } + + await notifyOrganization( + supply.fulfillmentCenterId, + `Селлер ${user.organization!.name} отменил поставку товаров`, + 'GOODS_SUPPLY_CANCELLED', + { orderId: args.id }, + ) + + console.log('✅ CANCEL_SELLER_GOODS_SUPPLY DOMAIN SUCCESS:', { supplyId: args.id }) + return cancelledSupply + } catch (error: any) { + console.error('❌ CANCEL_SELLER_GOODS_SUPPLY DOMAIN ERROR:', error) + + if (error instanceof GraphQLError) { + throw error + } + + throw new GraphQLError('Ошибка отмены товарной поставки') + } + }), + }, +} + +console.warn('🔥 SELLER GOODS DOMAIN МОДУЛЬ ЭКСПОРТЫ ГОТОВЫ') \ No newline at end of file diff --git a/src/graphql/resolvers/domains/services.ts b/src/graphql/resolvers/domains/services.ts new file mode 100644 index 0000000..7658b54 --- /dev/null +++ b/src/graphql/resolvers/domains/services.ts @@ -0,0 +1,784 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' + +// Services Domain Resolvers - управление услугами фулфилмента (мигрировано из fulfillment-services-v2.ts) + +console.warn('🔥 МОДУЛЬ SERVICES DOMAIN ЗАГРУЖАЕТСЯ') + +// ============================================================================= +// 🔐 AUTHENTICATION HELPERS +// ============================================================================= + +const withAuth = (resolver: any) => { + return async (parent: any, args: any, context: Context) => { + console.log('🔐 SERVICES DOMAIN AUTH CHECK:', { + hasUser: !!context.user, + userId: context.user?.id, + organizationId: context.user?.organizationId, + }) + if (!context.user) { + console.error('❌ AUTH FAILED: No user in context') + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + console.log('✅ AUTH PASSED: Calling resolver') + try { + const result = await resolver(parent, args, context) + console.log('🎯 RESOLVER RESULT TYPE:', typeof result, result === null ? 'NULL RESULT!' : 'Has result') + return result + } catch (error) { + console.error('💥 RESOLVER ERROR:', error) + throw error + } + } +} + +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 +} + +// ============================================================================= +// 🛠️ INTERFACE DEFINITIONS +// ============================================================================= + +interface CreateFulfillmentServiceInput { + name: string + description?: string + price: number + unit?: string + imageUrl?: string + sortOrder?: number +} + +interface UpdateFulfillmentServiceInput { + id: string + name?: string + description?: string + price?: number + unit?: string + imageUrl?: string + sortOrder?: number + isActive?: boolean +} + +interface CreateFulfillmentConsumableInput { + name: string + article?: string + description?: string + pricePerUnit: number + unit?: string + minStock?: number + currentStock?: number + imageUrl?: string + sortOrder?: number +} + +interface UpdateFulfillmentConsumableInput { + id: string + name?: string + nameForSeller?: string + article?: string + pricePerUnit?: number + unit?: string + minStock?: number + currentStock?: number + imageUrl?: string + sortOrder?: number + isAvailable?: boolean +} + +interface CreateFulfillmentLogisticsInput { + fromLocation: string + toLocation: string + fromAddress?: string + toAddress?: string + priceUnder1m3: number + priceOver1m3: number + estimatedDays: number + description?: string + sortOrder?: number +} + +interface UpdateFulfillmentLogisticsInput { + id: string + fromLocation?: string + toLocation?: string + fromAddress?: string + toAddress?: string + priceUnder1m3?: number + priceOver1m3?: number + estimatedDays?: number + description?: string + sortOrder?: number + isActive?: boolean +} + +// ============================================================================= +// 🛠️ SERVICES DOMAIN RESOLVERS +// ============================================================================= + +export const servicesResolvers: DomainResolvers = { + Query: { + // Мои услуги (для фулфилмента) + myFulfillmentServices: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_FULFILLMENT_SERVICES DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + const services = await prisma.fulfillmentService.findMany({ + where: { + fulfillmentId: user.organizationId!, + }, + include: { + fulfillment: true, + }, + orderBy: [ + { sortOrder: 'asc' }, + { name: 'asc' }, + ], + take: 200, // Добавляем пагинацию для производительности + }) + + console.log('✅ MY_FULFILLMENT_SERVICES DOMAIN SUCCESS:', { count: services.length }) + return services + } catch (error) { + console.error('❌ MY_FULFILLMENT_SERVICES DOMAIN ERROR:', error) + return [] + } + }), + + // Мои расходники (для фулфилмента) + myFulfillmentConsumables: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_FULFILLMENT_CONSUMABLES DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + const consumables = await prisma.fulfillmentConsumable.findMany({ + where: { + fulfillmentId: user.organizationId!, + }, + include: { + fulfillment: true, + inventory: { + include: { + product: true, + }, + }, + }, + orderBy: [ + { sortOrder: 'asc' }, + { name: 'asc' }, + ], + take: 200, // Добавляем пагинацию для производительности + }) + + console.log('✅ MY_FULFILLMENT_CONSUMABLES DOMAIN SUCCESS:', { + count: consumables.length, + firstThree: consumables.slice(0, 3).map(c => ({ + id: c.id, + name: c.name, + currentStock: c.currentStock, + isAvailable: c.isAvailable, + })) + }) + return consumables + } catch (error) { + console.error('❌ MY_FULFILLMENT_CONSUMABLES DOMAIN ERROR:', error) + return [] + } + }), + + // Моя логистика (для фулфилмента) + myFulfillmentLogistics: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_FULFILLMENT_LOGISTICS DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + const logistics = await prisma.fulfillmentLogistics.findMany({ + where: { + fulfillmentId: user.organizationId!, + }, + include: { + fulfillment: true, + }, + orderBy: [ + { sortOrder: 'asc' }, + { fromLocation: 'asc' }, + ], + take: 200, // Добавляем пагинацию для производительности + }) + + console.log('✅ MY_FULFILLMENT_LOGISTICS DOMAIN SUCCESS:', { count: logistics.length }) + return logistics + } catch (error) { + console.error('❌ MY_FULFILLMENT_LOGISTICS DOMAIN ERROR:', error) + return [] + } + }), + + // Услуги конкретного фулфилмента (для селлеров при создании поставки) + fulfillmentServicesById: withAuth(async (_: unknown, args: { fulfillmentId: string }, context: Context) => { + console.log('🔍 FULFILLMENT_SERVICES_BY_ID DOMAIN QUERY STARTED:', { + userId: context.user?.id, + fulfillmentId: args.fulfillmentId + }) + try { + const services = await prisma.fulfillmentService.findMany({ + where: { + fulfillmentId: args.fulfillmentId, + isActive: true, + }, + include: { + fulfillment: true, + }, + orderBy: [ + { sortOrder: 'asc' }, + { name: 'asc' }, + ], + }) + + console.log('✅ FULFILLMENT_SERVICES_BY_ID DOMAIN SUCCESS:', { count: services.length }) + return services + } catch (error) { + console.error('❌ FULFILLMENT_SERVICES_BY_ID DOMAIN ERROR:', error) + return [] + } + }), + + // Расходники конкретного фулфилмента (для селлеров при создании поставки) + fulfillmentConsumablesById: withAuth(async (_: unknown, args: { fulfillmentId: string }, context: Context) => { + console.log('🔍 FULFILLMENT_CONSUMABLES_BY_ID DOMAIN QUERY STARTED:', { + userId: context.user?.id, + fulfillmentId: args.fulfillmentId + }) + try { + const consumables = await prisma.fulfillmentConsumable.findMany({ + where: { + fulfillmentId: args.fulfillmentId, + isAvailable: true, + }, + include: { + fulfillment: true, + }, + orderBy: [ + { sortOrder: 'asc' }, + { name: 'asc' }, + ], + }) + + console.log('✅ FULFILLMENT_CONSUMABLES_BY_ID DOMAIN SUCCESS:', { count: consumables.length }) + return consumables + } catch (error) { + console.error('❌ FULFILLMENT_CONSUMABLES_BY_ID DOMAIN ERROR:', error) + return [] + } + }), + }, + + Mutation: { + // ============================================================================= + // 🔧 МУТАЦИИ ДЛЯ УСЛУГ ФУЛФИЛМЕНТА + // ============================================================================= + + // Создание услуги + createFulfillmentService: withAuth(async ( + _: unknown, + args: { input: CreateFulfillmentServiceInput }, + context: Context, + ) => { + console.log('🔍 CREATE_FULFILLMENT_SERVICE DOMAIN MUTATION STARTED:', { userId: context.user?.id }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + const service = await prisma.fulfillmentService.create({ + data: { + fulfillmentId: user.organizationId!, + name: args.input.name, + description: args.input.description, + price: args.input.price, + unit: args.input.unit || 'шт', + imageUrl: args.input.imageUrl, + sortOrder: args.input.sortOrder || 0, + isActive: true, + }, + include: { + fulfillment: true, + }, + }) + + const result = { + success: true, + message: 'Услуга успешно создана', + service, + } + console.log('✅ CREATE_FULFILLMENT_SERVICE DOMAIN SUCCESS:', { serviceId: service.id }) + return result + } catch (error: any) { + console.error('❌ CREATE_FULFILLMENT_SERVICE DOMAIN ERROR:', error) + return { + success: false, + message: `Ошибка при создании услуги: ${error.message}`, + service: null, + } + } + }), + + // Обновление услуги + updateFulfillmentService: withAuth(async ( + _: unknown, + args: { input: UpdateFulfillmentServiceInput }, + context: Context, + ) => { + console.log('🔍 UPDATE_FULFILLMENT_SERVICE DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + serviceId: args.input.id + }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + // Проверяем что услуга принадлежит текущему фулфилменту + const existingService = await prisma.fulfillmentService.findFirst({ + where: { + id: args.input.id, + fulfillmentId: user.organizationId!, + }, + }) + + if (!existingService) { + return { + success: false, + message: 'Услуга не найдена или не принадлежит вашей организации', + service: null, + } + } + + const updateData: any = {} + if (args.input.name !== undefined) updateData.name = args.input.name + if (args.input.description !== undefined) updateData.description = args.input.description + if (args.input.price !== undefined) updateData.price = args.input.price + if (args.input.unit !== undefined) updateData.unit = args.input.unit + if (args.input.imageUrl !== undefined) updateData.imageUrl = args.input.imageUrl + if (args.input.sortOrder !== undefined) updateData.sortOrder = args.input.sortOrder + if (args.input.isActive !== undefined) updateData.isActive = args.input.isActive + + const service = await prisma.fulfillmentService.update({ + where: { id: args.input.id }, + data: updateData, + include: { + fulfillment: true, + }, + }) + + const result = { + success: true, + message: 'Услуга успешно обновлена', + service, + } + console.log('✅ UPDATE_FULFILLMENT_SERVICE DOMAIN SUCCESS:', { serviceId: service.id }) + return result + } catch (error: any) { + console.error('❌ UPDATE_FULFILLMENT_SERVICE DOMAIN ERROR:', error) + return { + success: false, + message: `Ошибка при обновлении услуги: ${error.message}`, + service: null, + } + } + }), + + // Удаление услуги + deleteFulfillmentService: withAuth(async (_: unknown, args: { id: string }, context: Context) => { + console.log('🔍 DELETE_FULFILLMENT_SERVICE DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + serviceId: args.id + }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + // Проверяем что услуга принадлежит текущему фулфилменту + const existingService = await prisma.fulfillmentService.findFirst({ + where: { + id: args.id, + fulfillmentId: user.organizationId!, + }, + }) + + if (!existingService) { + throw new GraphQLError('Услуга не найдена или не принадлежит вашей организации') + } + + await prisma.fulfillmentService.delete({ + where: { id: args.id }, + }) + + console.log('✅ DELETE_FULFILLMENT_SERVICE DOMAIN SUCCESS:', { serviceId: args.id }) + return true + } catch (error: any) { + console.error('❌ DELETE_FULFILLMENT_SERVICE DOMAIN ERROR:', error) + throw new GraphQLError(`Ошибка при удалении услуги: ${error.message}`) + } + }), + + // ============================================================================= + // 📦 МУТАЦИИ ДЛЯ РАСХОДНИКОВ ФУЛФИЛМЕНТА + // ============================================================================= + + // Создание расходника + createFulfillmentConsumable: withAuth(async ( + _: unknown, + args: { input: CreateFulfillmentConsumableInput }, + context: Context, + ) => { + console.log('🔍 CREATE_FULFILLMENT_CONSUMABLE DOMAIN MUTATION STARTED:', { userId: context.user?.id }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + const consumable = await prisma.fulfillmentConsumable.create({ + data: { + fulfillmentId: user.organizationId!, + name: args.input.name, + article: args.input.article, + description: args.input.description, + pricePerUnit: args.input.pricePerUnit, + unit: args.input.unit || 'шт', + minStock: args.input.minStock || 0, + currentStock: args.input.currentStock || 0, + isAvailable: (args.input.currentStock || 0) > 0, + imageUrl: args.input.imageUrl, + sortOrder: args.input.sortOrder || 0, + }, + include: { + fulfillment: true, + }, + }) + + const result = { + success: true, + message: 'Расходник успешно создан', + consumable, + } + console.log('✅ CREATE_FULFILLMENT_CONSUMABLE DOMAIN SUCCESS:', { consumableId: consumable.id }) + return result + } catch (error: any) { + console.error('❌ CREATE_FULFILLMENT_CONSUMABLE DOMAIN ERROR:', error) + return { + success: false, + message: `Ошибка при создании расходника: ${error.message}`, + consumable: null, + } + } + }), + + // Обновление расходника + updateFulfillmentConsumable: withAuth(async ( + _: unknown, + args: { input: UpdateFulfillmentConsumableInput }, + context: Context, + ) => { + console.log('🔍 UPDATE_FULFILLMENT_CONSUMABLE DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + consumableId: args.input.id + }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + // Проверяем что расходник принадлежит текущему фулфилменту + const existingConsumable = await prisma.fulfillmentConsumable.findFirst({ + where: { + id: args.input.id, + fulfillmentId: user.organizationId!, + }, + }) + + if (!existingConsumable) { + return { + success: false, + message: 'Расходник не найден или не принадлежит вашей организации', + consumable: null, + } + } + + const updateData: any = {} + if (args.input.name !== undefined) updateData.name = args.input.name + if (args.input.nameForSeller !== undefined) updateData.nameForSeller = args.input.nameForSeller + if (args.input.article !== undefined) updateData.article = args.input.article + if (args.input.pricePerUnit !== undefined) updateData.pricePerUnit = args.input.pricePerUnit + if (args.input.unit !== undefined) updateData.unit = args.input.unit + if (args.input.minStock !== undefined) updateData.minStock = args.input.minStock + if (args.input.currentStock !== undefined) { + updateData.currentStock = args.input.currentStock + updateData.isAvailable = args.input.currentStock > 0 + } + if (args.input.imageUrl !== undefined) updateData.imageUrl = args.input.imageUrl + if (args.input.sortOrder !== undefined) updateData.sortOrder = args.input.sortOrder + if (args.input.isAvailable !== undefined) updateData.isAvailable = args.input.isAvailable + + const consumable = await prisma.fulfillmentConsumable.update({ + where: { id: args.input.id }, + data: updateData, + include: { + fulfillment: true, + }, + }) + + const result = { + success: true, + message: 'Расходник успешно обновлен', + consumable, + } + console.log('✅ UPDATE_FULFILLMENT_CONSUMABLE DOMAIN SUCCESS:', { consumableId: consumable.id }) + return result + } catch (error: any) { + console.error('❌ UPDATE_FULFILLMENT_CONSUMABLE DOMAIN ERROR:', error) + return { + success: false, + message: `Ошибка при обновлении расходника: ${error.message}`, + consumable: null, + } + } + }), + + // Удаление расходника + deleteFulfillmentConsumable: withAuth(async (_: unknown, args: { id: string }, context: Context) => { + console.log('🔍 DELETE_FULFILLMENT_CONSUMABLE DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + consumableId: args.id + }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + // Проверяем что расходник принадлежит текущему фулфилменту + const existingConsumable = await prisma.fulfillmentConsumable.findFirst({ + where: { + id: args.id, + fulfillmentId: user.organizationId!, + }, + }) + + if (!existingConsumable) { + throw new GraphQLError('Расходник не найден или не принадлежит вашей организации') + } + + await prisma.fulfillmentConsumable.delete({ + where: { id: args.id }, + }) + + console.log('✅ DELETE_FULFILLMENT_CONSUMABLE DOMAIN SUCCESS:', { consumableId: args.id }) + return true + } catch (error: any) { + console.error('❌ DELETE_FULFILLMENT_CONSUMABLE DOMAIN ERROR:', error) + throw new GraphQLError(`Ошибка при удалении расходника: ${error.message}`) + } + }), + + // ============================================================================= + // 🚚 МУТАЦИИ ДЛЯ ЛОГИСТИКИ ФУЛФИЛМЕНТА + // ============================================================================= + + // Создание логистического маршрута + createFulfillmentLogistics: withAuth(async ( + _: unknown, + args: { input: CreateFulfillmentLogisticsInput }, + context: Context, + ) => { + console.log('🔍 CREATE_FULFILLMENT_LOGISTICS DOMAIN MUTATION STARTED:', { userId: context.user?.id }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + const logistics = await prisma.fulfillmentLogistics.create({ + data: { + fulfillmentId: user.organizationId!, + fromLocation: args.input.fromLocation, + toLocation: args.input.toLocation, + fromAddress: args.input.fromAddress, + toAddress: args.input.toAddress, + priceUnder1m3: args.input.priceUnder1m3, + priceOver1m3: args.input.priceOver1m3, + estimatedDays: args.input.estimatedDays, + description: args.input.description, + isActive: true, + sortOrder: args.input.sortOrder || 0, + }, + include: { + fulfillment: true, + }, + }) + + const result = { + success: true, + message: 'Логистический маршрут успешно создан', + logistics, + } + console.log('✅ CREATE_FULFILLMENT_LOGISTICS DOMAIN SUCCESS:', { logisticsId: logistics.id }) + return result + } catch (error: any) { + console.error('❌ CREATE_FULFILLMENT_LOGISTICS DOMAIN ERROR:', error) + return { + success: false, + message: `Ошибка при создании логистического маршрута: ${error.message}`, + logistics: null, + } + } + }), + + // Обновление логистического маршрута + updateFulfillmentLogistics: withAuth(async ( + _: unknown, + args: { input: UpdateFulfillmentLogisticsInput }, + context: Context, + ) => { + console.log('🔍 UPDATE_FULFILLMENT_LOGISTICS DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + logisticsId: args.input.id + }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + // Проверяем что маршрут принадлежит текущему фулфилменту + const existingLogistics = await prisma.fulfillmentLogistics.findFirst({ + where: { + id: args.input.id, + fulfillmentId: user.organizationId!, + }, + }) + + if (!existingLogistics) { + return { + success: false, + message: 'Логистический маршрут не найден или не принадлежит вашей организации', + logistics: null, + } + } + + const updateData: any = {} + if (args.input.fromLocation !== undefined) updateData.fromLocation = args.input.fromLocation + if (args.input.toLocation !== undefined) updateData.toLocation = args.input.toLocation + if (args.input.fromAddress !== undefined) updateData.fromAddress = args.input.fromAddress + if (args.input.toAddress !== undefined) updateData.toAddress = args.input.toAddress + if (args.input.priceUnder1m3 !== undefined) updateData.priceUnder1m3 = args.input.priceUnder1m3 + if (args.input.priceOver1m3 !== undefined) updateData.priceOver1m3 = args.input.priceOver1m3 + if (args.input.estimatedDays !== undefined) updateData.estimatedDays = args.input.estimatedDays + if (args.input.description !== undefined) updateData.description = args.input.description + if (args.input.sortOrder !== undefined) updateData.sortOrder = args.input.sortOrder + if (args.input.isActive !== undefined) updateData.isActive = args.input.isActive + + const logistics = await prisma.fulfillmentLogistics.update({ + where: { id: args.input.id }, + data: updateData, + include: { + fulfillment: true, + }, + }) + + const result = { + success: true, + message: 'Логистический маршрут успешно обновлен', + logistics, + } + console.log('✅ UPDATE_FULFILLMENT_LOGISTICS DOMAIN SUCCESS:', { logisticsId: logistics.id }) + return result + } catch (error: any) { + console.error('❌ UPDATE_FULFILLMENT_LOGISTICS DOMAIN ERROR:', error) + return { + success: false, + message: `Ошибка при обновлении логистического маршрута: ${error.message}`, + logistics: null, + } + } + }), + + // Удаление логистического маршрута + deleteFulfillmentLogistics: withAuth(async (_: unknown, args: { id: string }, context: Context) => { + console.log('🔍 DELETE_FULFILLMENT_LOGISTICS DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + logisticsId: args.id + }) + try { + const user = await checkFulfillmentAccess(context.user!.id) + + // Проверяем что маршрут принадлежит текущему фулфилменту + const existingLogistics = await prisma.fulfillmentLogistics.findFirst({ + where: { + id: args.id, + fulfillmentId: user.organizationId!, + }, + }) + + if (!existingLogistics) { + throw new GraphQLError('Логистический маршрут не найден или не принадлежит вашей организации') + } + + await prisma.fulfillmentLogistics.delete({ + where: { id: args.id }, + }) + + console.log('✅ DELETE_FULFILLMENT_LOGISTICS DOMAIN SUCCESS:', { logisticsId: args.id }) + return true + } catch (error: any) { + console.error('❌ DELETE_FULFILLMENT_LOGISTICS DOMAIN ERROR:', error) + throw new GraphQLError(`Ошибка при удалении логистического маршрута: ${error.message}`) + } + }), + + // V1 Legacy: Обновить цену инвентаря фулфилмента + updateFulfillmentInventoryPrice: withAuth(async ( + _: unknown, + args: { + inventoryId: string + price: number + priceType?: string + notes?: string + }, + context: Context, + ) => { + console.warn('💰 UPDATE_FULFILLMENT_INVENTORY_PRICE (V1) - LEGACY RESOLVER:', { + inventoryId: args.inventoryId, + price: args.price, + priceType: args.priceType, + }) + + try { + const user = await checkFulfillmentAccess(context.user!.id) + + // TODO: Реализовать V1 логику обновления цены инвентаря фулфилмента + // Может потребоваться интеграция с V2 системой ценообразования услуг + + return { + success: false, + message: 'V1 Legacy - требуется реализация обновления цены инвентаря фулфилмента', + inventory: null, + } + } catch (error: any) { + console.error('❌ UPDATE_FULFILLMENT_INVENTORY_PRICE DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка при обновлении цены инвентаря', + inventory: null, + } + } + }), + }, +} + +console.warn('🔥 SERVICES DOMAIN МОДУЛЬ ЭКСПОРТЫ ГОТОВЫ') \ No newline at end of file diff --git a/src/graphql/resolvers/domains/supplies.ts b/src/graphql/resolvers/domains/supplies.ts new file mode 100644 index 0000000..d9cb1c8 --- /dev/null +++ b/src/graphql/resolvers/domains/supplies.ts @@ -0,0 +1,688 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' + +// Supplies Domain Resolvers - управление поставками расходников и товаров +export const suppliesResolvers: DomainResolvers = { + Query: { + // Мои поставки (для селлеров и фулфилмента) + mySupplies: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + const organizationId = currentUser.organization.id + const organizationType = currentUser.organization.type + + console.warn('📦 MY_SUPPLIES RESOLVER CALLED:', { + userId: context.user.id, + organizationId, + organizationType, + timestamp: new Date().toISOString(), + }) + + let whereClause + if (organizationType === 'WHOLESALE') { + // Для поставщиков показываем их товары + whereClause = { organizationId } + } else if (organizationType === 'SELLER') { + // Для селлеров показываем расходники + whereClause = { organizationId } + } else if (organizationType === 'FULFILLMENT') { + // Для фулфилмента показываем V2 система расходников + whereClause = { + OR: [ + { organizationId }, // Наши расходники + { fulfillmentCenterId: organizationId }, // Расходники партнеров на нашем складе + ], + } + } else { + whereClause = { organizationId } + } + + const supplies = await prisma.supplyOrder.findMany({ + where: whereClause, + include: { + organization: { + select: { + id: true, + name: true, + fullName: true, + type: true, + }, + }, + fulfillmentCenter: { + select: { + id: true, + name: true, + fullName: true, + type: true, + }, + }, + logisticsPartner: { + select: { + id: true, + name: true, + fullName: true, + type: true, + }, + }, + items: { + include: { + product: { + select: { + id: true, + name: true, + article: true, + type: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }) + + console.warn('📊 MY_SUPPLIES RESULT:', { + suppliesCount: supplies.length, + organizationType, + statuses: supplies.map((s) => s.status), + }) + + return supplies + }, + + // Заказы поставок (для управления) + supplyOrders: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + const organizationId = currentUser.organization.id + const organizationType = currentUser.organization.type + + console.warn('📋 SUPPLY_ORDERS RESOLVER CALLED:', { + organizationId, + organizationType, + timestamp: new Date().toISOString(), + }) + + // Определяем фильтр в зависимости от типа организации + let whereClause + if (organizationType === 'FULFILLMENT') { + // Фулфилмент видит заказы поставок на свой склад + whereClause = { fulfillmentCenterId: organizationId } + } else if (organizationType === 'SELLER') { + // Селлер видит свои заказы поставок + whereClause = { organizationId } + } else if (organizationType === 'WHOLESALE') { + // Поставщик видит заказы своих товаров + whereClause = { organizationId } + } else { + whereClause = { organizationId } + } + + const supplyOrders = await prisma.supplyOrder.findMany({ + where: whereClause, + include: { + organization: { + select: { + id: true, + name: true, + fullName: true, + type: true, + }, + }, + fulfillmentCenter: { + select: { + id: true, + name: true, + fullName: true, + type: true, + }, + }, + logisticsPartner: { + select: { + id: true, + name: true, + fullName: true, + type: true, + }, + }, + items: { + include: { + product: { + select: { + id: true, + name: true, + article: true, + type: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: 100, // Ограничиваем результаты + }) + + // Группируем для удобства + const pendingSupplyOrders = supplyOrders.filter((order) => order.status === 'PENDING') + const ourSupplyOrders = supplyOrders.filter((order) => order.organizationId === organizationId) + const sellerSupplyOrders = supplyOrders.filter( + (order) => order.organizationId !== organizationId && order.fulfillmentCenterId === organizationId, + ) + + console.warn('📊 SUPPLY_ORDERS RESULT:', { + totalOrders: supplyOrders.length, + pendingCount: pendingSupplyOrders.length, + ourOrdersCount: ourSupplyOrders.length, + sellerOrdersCount: sellerSupplyOrders.length, + }) + + return { + supplyOrders: pendingSupplyOrders, + ourSupplyOrders: ourSupplyOrders, // Расходники фулфилмента + sellerSupplyOrders: sellerSupplyOrders, // Расходники селлеров + } + }, + + // V1 Legacy: Поставщики поставок + supplySuppliers: async (_: unknown, __: unknown, context: Context) => { + console.warn('🏢 SUPPLY_SUPPLIERS (V1) - LEGACY RESOLVER') + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + // TODO: Реализовать логику получения поставщиков поставок + // Это V1 legacy резолвер - может потребоваться миграция на V2 + return [] + }, + }, + + Mutation: { + // ДУБЛИРОВАННЫЙ РЕЗОЛВЕР УДАЛЕН: createSupplyOrder + // Этот резолвер перемещен в supply-orders.ts для соответствия GraphQL схеме + + // Обновить статус заказа поставки + updateSupplyOrderStatus: async ( + _: unknown, + args: { + id: string + status: string + notes?: string + }, + context: Context, + ) => { + console.warn('📝 UPDATE_SUPPLY_ORDER_STATUS - ВЫЗВАН:', { + supplyOrderId: args.id, + newStatus: args.status, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + try { + // Находим заказ поставки + const supplyOrder = await prisma.supplyOrder.findFirst({ + where: { + id: args.id, + OR: [ + { organizationId: currentUser.organization.id }, // Создатель заказа + { fulfillmentCenterId: currentUser.organization.id }, // Фулфилмент центр + { logisticsPartnerId: currentUser.organization.id }, // Логистическая компания + ], + }, + include: { + organization: true, + fulfillmentCenter: true, + logisticsPartner: true, + }, + }) + + if (!supplyOrder) { + return { + success: false, + message: 'Заказ поставки не найден или нет доступа', + supplyOrder: null, + } + } + + // Проверяем валидность перехода статуса + const validTransitions: Record = { + PENDING: ['CONFIRMED', 'CANCELLED'], + CONFIRMED: ['IN_TRANSIT', 'CANCELLED'], + IN_TRANSIT: ['DELIVERED', 'CANCELLED'], + DELIVERED: ['COMPLETED'], + } + + if (!validTransitions[supplyOrder.status]?.includes(args.status)) { + return { + success: false, + message: `Недопустимый переход статуса с ${supplyOrder.status} на ${args.status}`, + supplyOrder: null, + } + } + + // Обновляем статус заказа + const updatedSupplyOrder = await prisma.supplyOrder.update({ + where: { id: args.id }, + data: { + status: args.status, + notes: args.notes || supplyOrder.notes, + updatedAt: new Date(), + }, + include: { + organization: true, + fulfillmentCenter: true, + logisticsPartner: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + console.warn('✅ СТАТУС ЗАКАЗА ПОСТАВКИ ОБНОВЛЕН:', { + supplyOrderId: args.id, + oldStatus: supplyOrder.status, + newStatus: args.status, + organizationType: currentUser.organization.type, + }) + + return { + success: true, + message: `Статус заказа изменен на ${args.status}`, + supplyOrder: updatedSupplyOrder, + } + } catch (error) { + console.error('Error updating supply order status:', error) + return { + success: false, + message: 'Ошибка при обновлении статуса заказа', + supplyOrder: null, + } + } + }, + + // Назначить логистику к заказу поставки + assignLogisticsToSupply: async ( + _: unknown, + args: { + supplyOrderId: string + logisticsPartnerId: string + deliveryDate?: string + notes?: string + }, + context: Context, + ) => { + console.warn('🚚 ASSIGN_LOGISTICS_TO_SUPPLY - ВЫЗВАН:', { + supplyOrderId: args.supplyOrderId, + logisticsPartnerId: args.logisticsPartnerId, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Только фулфилмент может назначать логистику + if (currentUser.organization.type !== 'FULFILLMENT') { + throw new GraphQLError('Только фулфилмент-центры могут назначать логистику') + } + + try { + // Проверяем заказ поставки + const supplyOrder = await prisma.supplyOrder.findFirst({ + where: { + id: args.supplyOrderId, + fulfillmentCenterId: currentUser.organization.id, + status: { in: ['PENDING', 'CONFIRMED'] }, + }, + }) + + if (!supplyOrder) { + return { + success: false, + message: 'Заказ поставки не найден или уже назначен', + supplyOrder: null, + } + } + + // Проверяем логистическую компанию + const logisticsPartner = await prisma.organization.findFirst({ + where: { + id: args.logisticsPartnerId, + type: 'LOGIST', + }, + }) + + if (!logisticsPartner) { + return { + success: false, + message: 'Логистическая компания не найдена', + supplyOrder: null, + } + } + + // Назначаем логистику + const updatedSupplyOrder = await prisma.supplyOrder.update({ + where: { id: args.supplyOrderId }, + data: { + logisticsPartnerId: args.logisticsPartnerId, + deliveryDate: args.deliveryDate ? new Date(args.deliveryDate) : supplyOrder.deliveryDate, + status: 'CONFIRMED', + notes: args.notes || supplyOrder.notes, + updatedAt: new Date(), + }, + include: { + organization: true, + fulfillmentCenter: true, + logisticsPartner: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + console.warn('✅ ЛОГИСТИКА НАЗНАЧЕНА НА ЗАКАЗ:', { + supplyOrderId: args.supplyOrderId, + logisticsPartnerId: args.logisticsPartnerId, + logisticsPartnerName: logisticsPartner.name || logisticsPartner.fullName, + deliveryDate: args.deliveryDate, + }) + + return { + success: true, + message: 'Логистика успешно назначена на заказ', + supplyOrder: updatedSupplyOrder, + } + } catch (error) { + console.error('Error assigning logistics to supply:', error) + return { + success: false, + message: 'Ошибка при назначении логистики', + supplyOrder: null, + } + } + }, + + // Удалить заказ поставки (только до отгрузки) + deleteSupplyOrder: async (_: unknown, args: { id: string }, context: Context) => { + console.warn('🗑️ DELETE_SUPPLY_ORDER - ВЫЗВАН:', { + supplyOrderId: args.id, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + try { + // Находим заказ поставки + const supplyOrder = await prisma.supplyOrder.findFirst({ + where: { + id: args.id, + organizationId: currentUser.organization.id, // Только создатель может удалить + status: { in: ['PENDING', 'CANCELLED'] }, // Можно удалить только ожидающие или отмененные + }, + }) + + if (!supplyOrder) { + return { + success: false, + message: 'Заказ поставки не найден или не может быть удален', + } + } + + // Удаляем заказ и связанные позиции (каскадно через Prisma) + await prisma.supplyOrder.delete({ + where: { id: args.id }, + }) + + console.warn('🗑️ ЗАКАЗ ПОСТАВКИ УДАЛЕН:', { + supplyOrderId: args.id, + organizationId: currentUser.organization.id, + status: supplyOrder.status, + }) + + return { + success: true, + message: 'Заказ поставки успешно удален', + } + } catch (error) { + console.error('Error deleting supply order:', error) + return { + success: false, + message: 'Ошибка при удалении заказа поставки', + } + } + }, + + // Массовое обновление статусов заказов + bulkUpdateSupplyOrders: async ( + _: unknown, + args: { + ids: string[] + status: string + notes?: string + }, + context: Context, + ) => { + console.warn('📝 BULK_UPDATE_SUPPLY_ORDERS - ВЫЗВАН:', { + orderIds: args.ids, + newStatus: args.status, + count: args.ids.length, + timestamp: new Date().toISOString(), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + try { + // Обновляем статусы всех указанных заказов + const result = await prisma.supplyOrder.updateMany({ + where: { + id: { in: args.ids }, + OR: [ + { organizationId: currentUser.organization.id }, // Создатель заказа + { fulfillmentCenterId: currentUser.organization.id }, // Фулфилмент центр + { logisticsPartnerId: currentUser.organization.id }, // Логистическая компания + ], + }, + data: { + status: args.status, + notes: args.notes, + updatedAt: new Date(), + }, + }) + + console.warn('✅ МАССОВОЕ ОБНОВЛЕНИЕ СТАТУСОВ:', { + requestedCount: args.ids.length, + updatedCount: result.count, + newStatus: args.status, + organizationType: currentUser.organization.type, + }) + + return { + success: true, + message: `Обновлено ${result.count} заказов из ${args.ids.length}`, + updatedCount: result.count, + } + } catch (error) { + console.error('Error bulk updating supply orders:', error) + return { + success: false, + message: 'Ошибка при массовом обновлении заказов', + updatedCount: 0, + } + } + }, + + // V1 Legacy: Обновить цену поставки + updateSupplyPrice: async ( + _: unknown, + args: { + supplyId: string + price: number + priceType?: string + }, + context: Context, + ) => { + console.warn('💰 UPDATE_SUPPLY_PRICE (V1) - LEGACY RESOLVER:', { + supplyId: args.supplyId, + price: args.price, + priceType: args.priceType, + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + // TODO: Реализовать V1 логику обновления цены поставки + // Может потребоваться миграция на V2 систему ценообразования + return { + success: false, + message: 'V1 Legacy - требуется реализация', + } + }, + + // V1 Legacy: Создать поставщика поставки + createSupplySupplier: async ( + _: unknown, + args: { + input: { + organizationId: string + supplyId: string + terms?: string + contactInfo?: string + } + }, + context: Context, + ) => { + console.warn('🏭 CREATE_SUPPLY_SUPPLIER (V1) - LEGACY RESOLVER:', args.input) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + // TODO: Реализовать V1 логику создания поставщика поставки + // Может потребоваться миграция на V2 систему управления поставщиками + return { + success: false, + message: 'V1 Legacy - требуется реализация', + supplier: null, + } + }, + + // V1 Legacy: Обновить параметры поставки + updateSupplyParameters: async ( + _: unknown, + args: { + supplyId: string + parameters: Record + }, + context: Context, + ) => { + console.warn('⚙️ UPDATE_SUPPLY_PARAMETERS (V1) - LEGACY RESOLVER:', { + supplyId: args.supplyId, + parametersKeys: Object.keys(args.parameters || {}), + }) + + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + // TODO: Реализовать V1 логику обновления параметров поставки + // Может потребоваться миграция на V2 систему конфигурации + return { + success: false, + message: 'V1 Legacy - требуется реализация', + supply: null, + } + }, + }, +} \ No newline at end of file diff --git a/src/graphql/resolvers/domains/supply-orders.ts b/src/graphql/resolvers/domains/supply-orders.ts new file mode 100644 index 0000000..8c8b53e --- /dev/null +++ b/src/graphql/resolvers/domains/supply-orders.ts @@ -0,0 +1,479 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' +import { getCurrentUser } from '../shared/auth-utils' + +// Supply Orders Domain Resolvers - изолированная логика заказов поставок +export const supplyOrdersResolvers: DomainResolvers = { + Query: { + // Мои заказы поставок + mySupplyOrders: async (_: unknown, __: unknown, context: Context) => { + const currentUser = await getCurrentUser(context) + + console.warn('🔍 GET MY SUPPLY ORDERS:', { + userId: currentUser.id, + organizationType: currentUser.organization.type, + organizationId: currentUser.organization.id, + }) + + // Определяем логику фильтрации в зависимости от типа организации + let whereClause + if (currentUser.organization.type === 'WHOLESALE') { + // Поставщик видит заказы, где он является поставщиком (partnerId) + whereClause = { + partnerId: currentUser.organization.id, + } + } else { + // Остальные (SELLER, FULFILLMENT) видят заказы, которые они создали (organizationId) + whereClause = { + organizationId: currentUser.organization.id, + } + } + + const supplyOrders = await prisma.supplyOrder.findMany({ + where: whereClause, + select: { + id: true, + status: true, + totalAmount: true, + deliveryDate: true, + createdAt: true, + updatedAt: true, + notes: true, + consumableType: true, + packagesCount: true, + volume: true, + partner: { + select: { id: true, name: true, type: true }, + }, + organization: { + select: { id: true, name: true, type: true }, + }, + fulfillmentCenter: { + select: { id: true, name: true, type: true }, + }, + logisticsPartner: { + select: { id: true, name: true, type: true }, + }, + items: { + select: { + id: true, + quantity: true, + price: true, + total: true, + product: { + select: { + id: true, + name: true, + price: true, + category: { + select: { id: true, name: true }, + }, + }, + }, + }, + orderBy: { + createdAt: 'asc', + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: 100, // Добавляем пагинацию + }) + + console.warn('📦 Найдено поставок:', supplyOrders.length, { + organizationType: currentUser.organization.type, + filterType: currentUser.organization.type === 'WHOLESALE' ? 'partnerId' : 'organizationId', + organizationId: currentUser.organization.id, + }) + + return supplyOrders + }, + + // Количество ожидающих поставок + pendingSuppliesCount: async (_: unknown, __: unknown, context: Context) => { + const currentUser = await getCurrentUser(context) + + // Считаем заказы поставок, требующие действий + + // Расходники фулфилмента (созданные нами для себя) - требуют действий по статусам + const ourSupplyOrders = await prisma.supplyOrder.count({ + where: { + organizationId: currentUser.organization.id, // Создали мы + fulfillmentCenterId: currentUser.organization.id, // Получатель - мы + status: { in: ['CONFIRMED', 'IN_TRANSIT'] }, // Подтверждено или в пути + }, + }) + + // Расходники селлеров (созданные другими для нас) - требуют действий фулфилмента + const sellerSupplyOrders = await prisma.supplyOrder.count({ + where: { + fulfillmentCenterId: currentUser.organization.id, // Получатель - мы + organizationId: { not: currentUser.organization.id }, // Создали НЕ мы + status: { + in: [ + 'SUPPLIER_APPROVED', // Поставщик подтвердил - нужно назначить логистику + 'IN_TRANSIT', // В пути - нужно подтвердить получение + ], + }, + }, + }) + + // ВХОДЯЩИЕ ЗАКАЗЫ ДЛЯ ПОСТАВЩИКОВ (WHOLESALE) - требуют подтверждения + const incomingSupplierOrders = await prisma.supplyOrder.count({ + where: { + partnerId: currentUser.organization.id, // Мы - поставщик + status: 'PENDING', // Ожидает подтверждения от поставщика + }, + }) + + // ЛОГИСТИЧЕСКИЕ ЗАЯВКИ ДЛЯ ЛОГИСТИКИ (LOGIST) - требуют действий логистики + const logisticsOrders = await prisma.supplyOrder.count({ + where: { + logisticsPartnerId: currentUser.organization.id, // Мы - логистика + status: 'LOGISTICS_CONFIRMED', // Требует действий логистики + }, + }) + + // Считаем общее количество задач + const totalPending = ourSupplyOrders + sellerSupplyOrders + incomingSupplierOrders + logisticsOrders + + console.warn('📊 PENDING SUPPLIES COUNT:', { + userId: currentUser.id, + organizationType: currentUser.organization.type, + ourSupplyOrders, + sellerSupplyOrders, + incomingSupplierOrders, + logisticsOrders, + totalPending, + }) + + return { + supplyOrders: totalPending, + ourSupplyOrders, + sellerSupplyOrders, + incomingSupplierOrders, + logisticsOrders, + incomingRequests: incomingSupplierOrders, // Алиас для совместимости со schema + total: totalPending, // Общий счетчик + } + }, + }, + + Mutation: { + // Создание заказа поставки + createSupplyOrder: async ( + _: unknown, + args: { + input: { + partnerId: string + deliveryDate: string + fulfillmentCenterId?: string // ID фулфилмент-центра для доставки + logisticsPartnerId?: string // ID логистической компании + items: Array<{ + productId: string + quantity: number + recipe?: { + services?: string[] + fulfillmentConsumables?: string[] + sellerConsumables?: string[] + marketplaceCardId?: string + } + }> + notes?: string // Дополнительные заметки к заказу + consumableType?: string // Классификация расходников + // Новые поля для многоуровневой системы + packagesCount?: number // Количество грузовых мест (заполняет поставщик) + volume?: number // Объём товара в м³ (заполняет поставщик) + routes?: Array<{ + logisticsId?: string // Ссылка на предустановленный маршрут + fromLocation: string // Точка забора + toLocation: string // Точка доставки + fromAddress?: string // Полный адрес забора + toAddress?: string // Полный адрес доставки + }> + } + }, + context: Context, + ) => { + console.warn('🚀 CREATE_SUPPLY_ORDER RESOLVER - ВЫЗВАН:', { + hasUser: !!context.user, + userId: context.user?.id, + inputData: args.input, + timestamp: new Date().toISOString(), + }) + + const currentUser = await getCurrentUser(context) + + console.warn('🔍 Проверка пользователя:', { + userId: context.user.id, + userFound: !!currentUser, + organizationFound: !!currentUser?.organization, + organizationType: currentUser?.organization?.type, + organizationId: currentUser?.organization?.id, + }) + + if (!currentUser) { + throw new GraphQLError('Пользователь не найден') + } + + if (!currentUser.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверяем тип организации и определяем роль в процессе поставки + const allowedTypes = ['FULFILLMENT', 'SELLER', 'LOGIST'] + if (!allowedTypes.includes(currentUser.organization.type)) { + throw new GraphQLError('Заказы поставок недоступны для данного типа организации') + } + + // Определяем роль организации в процессе поставки + const organizationRole = currentUser.organization.type + let fulfillmentCenterId = args.input.fulfillmentCenterId + + // Если заказ создает фулфилмент-центр, он сам является получателем + if (organizationRole === 'FULFILLMENT') { + fulfillmentCenterId = currentUser.organization.id + } + + try { + // Проверяем, что поставщик существует + const partner = await prisma.organization.findUnique({ + where: { id: args.input.partnerId }, + }) + + if (!partner || partner.type !== 'WHOLESALE') { + return { + success: false, + message: 'Поставщик не найден или не является поставщиком', + } + } + + // Подсчитываем общую стоимость заказа - ОПТИМИЗИРОВАННО: один запрос вместо N + const productIds = args.input.items.map(item => item.productId) + const products = await prisma.product.findMany({ + where: { + id: { in: productIds }, + organizationId: args.input.partnerId, // Сразу фильтруем по поставщику + }, + select: { + id: true, + name: true, + price: true, + organizationId: true, + }, + }) + + // Проверяем, что все товары найдены + if (products.length !== args.input.items.length) { + const foundProductIds = products.map(p => p.id) + const missingProducts = args.input.items.filter(item => !foundProductIds.includes(item.productId)) + return { + success: false, + message: `Товары не найдены или не принадлежат поставщику: ${missingProducts.map(p => p.productId).join(', ')}`, + } + } + + // Создаем мапу продуктов для быстрого доступа + const productMap = new Map(products.map(p => [p.id, p])) + + let totalAmount = 0 + const orderItems = [] + + for (const item of args.input.items) { + const product = productMap.get(item.productId)! + const itemTotal = product.price * item.quantity + totalAmount += itemTotal + + orderItems.push({ + productId: item.productId, + quantity: item.quantity, + price: product.price, + total: itemTotal, + services: item.recipe?.services ? JSON.stringify(item.recipe.services) : null, + fulfillmentConsumables: item.recipe?.fulfillmentConsumables ? JSON.stringify(item.recipe.fulfillmentConsumables) : null, + sellerConsumables: item.recipe?.sellerConsumables ? JSON.stringify(item.recipe.sellerConsumables) : null, + marketplaceCardId: item.recipe?.marketplaceCardId || null, + }) + } + + // Создаем заказ поставки + const supplyOrder = await prisma.supplyOrder.create({ + data: { + organizationId: currentUser.organization.id, // Кто создал заказ + partnerId: args.input.partnerId, // Поставщик + fulfillmentCenterId: fulfillmentCenterId || null, // Получатель + logisticsPartnerId: args.input.logisticsPartnerId || null, // Логистическая компания + deliveryDate: new Date(args.input.deliveryDate), + status: 'PENDING', // Начальный статус + totalAmount, + notes: args.input.notes, + consumableType: args.input.consumableType, + packagesCount: args.input.packagesCount, + volume: args.input.volume, + routes: args.input.routes ? JSON.stringify(args.input.routes) : null, + items: { + create: orderItems, + }, + }, + include: { + partner: true, + organization: true, + fulfillmentCenter: true, + logisticsPartner: true, + items: { + include: { + product: true, + }, + }, + }, + }) + + console.warn('✅ ЗАКАЗ ПОСТАВКИ СОЗДАН:', { + orderId: supplyOrder.id, + organizationId: currentUser.organization.id, + partnerId: args.input.partnerId, + totalAmount, + itemsCount: orderItems.length, + status: supplyOrder.status, + }) + + return { + success: true, + message: 'Заказ поставки успешно создан', + supplyOrder, + } + } catch (error) { + console.error('Error creating supply order:', error) + return { + success: false, + message: 'Ошибка при создании заказа поставки', + } + } + }, + + // Обновление статуса заказа поставки + updateSupplyOrderStatus: async ( + _: unknown, + args: { + id: string + status: + | 'PENDING' + | 'CONFIRMED' + | 'IN_TRANSIT' + | 'SUPPLIER_APPROVED' + | 'LOGISTICS_CONFIRMED' + | 'SHIPPED' + | 'DELIVERED' + | 'CANCELLED' + }, + context: Context, + ) => { + console.warn(`[DEBUG] updateSupplyOrderStatus вызван для заказа ${args.id} со статусом ${args.status}`) + const currentUser = await getCurrentUser(context) + + try { + // Находим заказ поставки + const existingOrder = await prisma.supplyOrder.findFirst({ + where: { + id: args.id, + OR: [ + { organizationId: currentUser.organization.id }, // Создатель заказа + { partnerId: currentUser.organization.id }, // Поставщик + { fulfillmentCenterId: currentUser.organization.id }, // Фулфилмент-центр + ], + }, + include: { + items: { + include: { + product: { + include: { + category: true, + }, + }, + }, + }, + partner: true, + organization: true, + fulfillmentCenter: true, + logisticsPartner: true, + }, + }) + + if (!existingOrder) { + return { + success: false, + message: 'Заказ поставки не найден или нет доступа', + } + } + + // Проверяем валидность перехода статуса + const validTransitions: Record = { + PENDING: ['SUPPLIER_APPROVED', 'CANCELLED'], + SUPPLIER_APPROVED: ['LOGISTICS_CONFIRMED', 'CANCELLED'], + LOGISTICS_CONFIRMED: ['IN_TRANSIT', 'CANCELLED'], + IN_TRANSIT: ['DELIVERED', 'CANCELLED'], + DELIVERED: [], // Финальный статус + CANCELLED: [], // Финальный статус + } + + const currentStatus = existingOrder.status + if (!validTransitions[currentStatus]?.includes(args.status)) { + return { + success: false, + message: `Нельзя изменить статус с "${currentStatus}" на "${args.status}"`, + } + } + + // Обновляем статус заказа + const updatedOrder = await prisma.supplyOrder.update({ + where: { id: args.id }, + data: { + status: args.status, + updatedAt: new Date(), + }, + include: { + partner: true, + organization: true, + fulfillmentCenter: true, + logisticsPartner: true, + items: { + include: { + product: { + include: { + category: true, + organization: true, + }, + }, + }, + }, + }, + }) + + console.warn('✅ СТАТУС ЗАКАЗА ОБНОВЛЕН:', { + orderId: args.id, + oldStatus: currentStatus, + newStatus: args.status, + organizationId: currentUser.organization.id, + organizationType: currentUser.organization.type, + }) + + return { + success: true, + message: `Статус заказа обновлен на "${args.status}"`, + supplyOrder: updatedOrder, + } + } catch (error) { + console.error('Error updating supply order status:', error) + return { + success: false, + message: 'Ошибка при обновлении статуса заказа', + } + } + }, + }, +} \ No newline at end of file diff --git a/src/graphql/resolvers/domains/user-management.ts b/src/graphql/resolvers/domains/user-management.ts new file mode 100644 index 0000000..721ced8 --- /dev/null +++ b/src/graphql/resolvers/domains/user-management.ts @@ -0,0 +1,410 @@ +import { GraphQLError } from 'graphql' +import * as jwt from 'jsonwebtoken' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' +import { DaDataService } from '../../../services/dadata-service' +// import { smsService } from '../../../lib/sms' // TODO: импорт SMS сервиса + +// Инициализация DaData сервиса +const dadataService = new DaDataService() + +// Типы для JWT токена +interface AuthTokenPayload { + userId: string + phone: string +} + +// JWT утилита +const generateToken = (payload: AuthTokenPayload): string => { + return jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: '30d' }) +} + +// User Management Domain Resolvers - управление пользователями и профилем +export const userManagementResolvers: DomainResolvers = { + Query: { + // Получить текущего пользователя + me: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + return await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { + organization: { + include: { + apiKeys: true, + }, + }, + }, + }) + }, + }, + + Mutation: { + // Отправка SMS кода для верификации + sendSmsCode: async (_: unknown, args: { phone: string }) => { + // TODO: Реализовать SMS сервис + // const result = await smsService.sendSmsCode(args.phone) + return { + success: true, + message: 'SMS код отправлен (заглушка)', + } + }, + + // Проверка SMS кода + verifySmsCode: async (_: unknown, args: { phone: string; code: string }) => { + // TODO: Реализовать SMS верификацию + // const verificationResult = await smsService.verifySmsCode(args.phone, args.code) + + // Заглушка для демо + if (args.code !== '1234') { + return { + success: false, + message: 'Неверный код (используйте 1234 для демо)', + token: null, + user: null, + } + } + + // Ищем существующего пользователя по номеру телефона + let user = await prisma.user.findUnique({ + where: { phone: args.phone }, + include: { + organization: { + include: { + apiKeys: true, + }, + }, + }, + }) + + // Если пользователь не найден, создаем нового + if (!user) { + user = await prisma.user.create({ + data: { + phone: args.phone, + // Остальные поля будут заполнены при регистрации + }, + include: { + organization: { + include: { + apiKeys: true, + }, + }, + }, + }) + } + + // Создаем JWT токен + const token = generateToken({ + userId: user.id, + phone: user.phone, + }) + + return { + success: true, + message: 'Код верифицирован успешно', + token, + user, + } + }, + + // Проверка ИНН через DaData API + verifyInn: async (_: unknown, args: { inn: string }) => { + console.log('🔍 VERIFY_INN STARTED:', { inn: args.inn }) + + // Базовая проверка длины ИНН + if (!args.inn || (args.inn.length !== 10 && args.inn.length !== 12)) { + console.error('❌ VERIFY_INN: Некорректная длина ИНН:', args.inn) + return { + success: false, + message: 'Некорректный ИНН. ИНН должен содержать 10 или 12 цифр', + organization: null, + } + } + + try { + // Валидация ИНН по контрольной сумме + if (!dadataService.validateInn(args.inn)) { + console.error('❌ VERIFY_INN: ИНН не прошел валидацию контрольной суммы:', args.inn) + return { + success: false, + message: 'Некорректный ИНН. Проверьте правильность введенных цифр', + organization: null, + } + } + + console.log('✅ VERIFY_INN: ИНН прошел валидацию, запрашиваем данные из DaData...') + + // Получение данных из DaData + const organizationData = await dadataService.getOrganizationByInn(args.inn) + + if (!organizationData) { + console.error('❌ VERIFY_INN: Организация не найдена в DaData:', args.inn) + return { + success: false, + message: 'Организация с таким ИНН не найдена', + organization: null, + } + } + + // Проверка статуса организации + if (!organizationData.isActive) { + console.warn('⚠️ VERIFY_INN: Организация неактивна:', { + inn: args.inn, + status: organizationData.status, + }) + return { + success: false, + message: 'Организация не активна или ликвидирована', + organization: null, + } + } + + console.log('✅ VERIFY_INN SUCCESS:', { + inn: organizationData.inn, + name: organizationData.name, + isActive: organizationData.isActive, + }) + + return { + success: true, + message: 'ИНН верифицирован успешно', + organization: { + name: organizationData.name, + fullName: organizationData.fullName, + address: organizationData.address, + isActive: organizationData.isActive, + }, + } + } catch (error) { + console.error('💥 VERIFY_INN ERROR:', error) + return { + success: false, + message: 'Ошибка при проверке ИНН. Попробуйте позже', + organization: null, + } + } + }, + + // Добавление API ключа маркетплейса + addMarketplaceApiKey: async ( + _: unknown, + args: { + input: { + marketplace: 'WILDBERRIES' | 'OZON' + apiKey: string + warehouseId?: string + supplierId?: string + campaignId?: string + clientId?: string + clientSecret?: string + } + }, + context: Context, + ) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверяем, что это селлер + if (currentUser.organization.type !== 'SELLER') { + throw new GraphQLError('API ключи доступны только для селлеров') + } + + try { + // TODO: Реализовать marketplace API keys когда модель будет готова + // Заглушка для демо + return { + success: true, + message: `API ключ ${args.input.marketplace} добавлен (заглушка)`, + apiKey: { + id: `api_key_${Date.now()}`, + marketplace: args.input.marketplace, + apiKey: args.input.apiKey, + warehouseId: args.input.warehouseId, + supplierId: args.input.supplierId, + campaignId: args.input.campaignId, + clientId: args.input.clientId, + clientSecret: args.input.clientSecret, + createdAt: new Date(), + updatedAt: new Date(), + }, + } + } catch (error) { + console.error('Error adding marketplace API key:', error) + return { + success: false, + message: 'Ошибка при добавлении API ключа', + apiKey: null, + } + } + }, + + // Удаление API ключа маркетплейса + removeMarketplaceApiKey: async (_: unknown, args: { marketplace: 'WILDBERRIES' | 'OZON' }, context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + try { + // TODO: Реализовать удаление API ключей когда модель будет готова + return { + success: true, + message: `API ключ ${args.marketplace} удален (заглушка)`, + } + } catch (error) { + console.error('Error removing marketplace API key:', error) + return { + success: false, + message: 'Ошибка при удалении API ключа', + } + } + }, + + // Обновление профиля пользователя + updateUserProfile: async ( + _: unknown, + args: { + input: { + fullName?: string + firstName?: string + lastName?: string + middleName?: string + email?: string + phone?: string + avatarUrl?: string + position?: string + contactPerson?: string + contactPhone?: string + contactEmail?: string + } + }, + context: Context, + ) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + try { + // TODO: Проверка уникальности email когда поле будет в модели + // if (args.input.email) { + // const existingUser = await prisma.user.findFirst({ + // where: { + // email: args.input.email, + // NOT: { id: context.user.id }, + // }, + // }) + // + // if (existingUser) { + // return { + // success: false, + // message: 'Email уже используется другим пользователем', + // user: null, + // } + // } + // } + + // Проверяем уникальность телефона, если он изменяется + if (args.input.phone) { + const existingUser = await prisma.user.findFirst({ + where: { + phone: args.input.phone, + NOT: { id: context.user.id }, + }, + }) + + if (existingUser) { + return { + success: false, + message: 'Телефон уже используется другим пользователем', + user: null, + } + } + } + + // Обновляем профиль пользователя (только доступные поля) + const updatedUser = await prisma.user.update({ + where: { id: context.user.id }, + data: { + // TODO: Добавить остальные поля когда они будут в модели + ...(args.input.phone && { phone: args.input.phone }), + updatedAt: new Date(), + }, + include: { + organization: { + include: { + apiKeys: true, + }, + }, + }, + }) + + return { + success: true, + message: 'Профиль обновлен успешно', + user: updatedUser, + } + } catch (error) { + console.error('Error updating user profile:', error) + return { + success: false, + message: 'Ошибка при обновлении профиля', + user: null, + } + } + }, + }, + + // Типовой резолвер для User + User: { + organization: async (parent: { organizationId?: string; organization?: unknown }) => { + // Если организация уже загружена через include, возвращаем её + if (parent.organization) { + return parent.organization + } + + // Иначе загружаем отдельно если есть organizationId + if (parent.organizationId) { + return await prisma.organization.findUnique({ + where: { id: parent.organizationId }, + include: { + apiKeys: true, + users: true, + }, + }) + } + + return null + }, + }, +} \ No newline at end of file diff --git a/src/graphql/resolvers/domains/wildberries.ts b/src/graphql/resolvers/domains/wildberries.ts new file mode 100644 index 0000000..2224d07 --- /dev/null +++ b/src/graphql/resolvers/domains/wildberries.ts @@ -0,0 +1,786 @@ +import { GraphQLError } from 'graphql' + +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' +import { DomainResolvers } from '../shared/types' +import { MarketplaceService } from '../../../services/marketplace-service' +import { WildberriesService } from '../../../services/wildberries-service' + +// Wildberries & Marketplace Domain Resolvers - управление интеграцией с маркетплейсами + +// ============================================================================= +// 🔐 AUTHENTICATION HELPERS +// ============================================================================= + +const withAuth = (resolver: any) => { + return async (parent: any, args: any, context: Context) => { + console.log('🔐 WILDBERRIES DOMAIN AUTH CHECK:', { + hasUser: !!context.user, + userId: context.user?.id, + organizationId: context.user?.organizationId, + }) + if (!context.user) { + console.error('❌ AUTH FAILED: No user in context') + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + console.log('✅ AUTH PASSED: Calling resolver') + try { + const result = await resolver(parent, args, context) + console.log('🎯 RESOLVER RESULT TYPE:', typeof result, result === null ? 'NULL RESULT!' : 'Has result') + return result + } catch (error) { + console.error('💥 RESOLVER ERROR:', error) + throw error + } + } +} + +// Сервисы +const marketplaceService = new MarketplaceService() +// WildberriesService требует API ключ в конструкторе, создадим экземпляры в резолверах + +// ============================================================================= +// 🛍️ WILDBERRIES & MARKETPLACE DOMAIN RESOLVERS +// ============================================================================= + +export const wildberriesResolvers: DomainResolvers = { + Query: { + // Мои поставки Wildberries + myWildberriesSupplies: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 MY_WILDBERRIES_SUPPLIES DOMAIN QUERY STARTED:', { userId: context.user?.id }) + try { + const currentUser = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + const supplies = await prisma.wildberriesSupply.findMany({ + where: { organizationId: currentUser.organization.id }, + include: { + organization: true, + cards: true, + }, + orderBy: { createdAt: 'desc' }, + }) + + console.log('✅ MY_WILDBERRIES_SUPPLIES DOMAIN SUCCESS:', { count: supplies.length }) + return supplies + } catch (error) { + console.error('❌ MY_WILDBERRIES_SUPPLIES DOMAIN ERROR:', error) + return [] + } + }), + + // Отладка рекламных кампаний Wildberries + debugWildberriesAdverts: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 DEBUG_WILDBERRIES_ADVERTS DOMAIN QUERY STARTED:', { userId: context.user?.id }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { + organization: { + include: { apiKeys: true } + } + } + }) + + if (!user?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + const wbApiKey = user.organization.apiKeys.find(key => key.marketplace === 'WILDBERRIES') + if (!wbApiKey) { + throw new GraphQLError('API ключ Wildberries не найден') + } + + console.log('🚀 FETCHING WB ADVERTS WITH API KEY:', { + organizationId: user.organization.id, + hasApiKey: !!wbApiKey.apiKey + }) + + const campaigns = await wildberriesService.getAdvertCampaigns(wbApiKey.apiKey) + + console.log('✅ DEBUG_WILDBERRIES_ADVERTS SUCCESS:', { campaignsCount: campaigns.length }) + + return { + success: true, + message: 'Кампании получены успешно', + campaignsCount: campaigns.length, + campaigns: campaigns.slice(0, 5) // Первые 5 для отладки + } + } catch (error) { + console.error('❌ DEBUG_WILDBERRIES_ADVERTS ERROR:', error) + return { + success: false, + message: `Ошибка получения кампаний: ${error instanceof Error ? error.message : 'Unknown error'}`, + campaignsCount: 0, + campaigns: [] + } + } + }), + + // Получение статистики Wildberries + getWildberriesStatistics: withAuth(async ( + _: unknown, + args: { period: string; startDate: string; endDate: string }, + context: Context, + ) => { + console.log('🔍 GET_WILDBERRIES_STATISTICS DOMAIN QUERY STARTED:', { + userId: context.user?.id, + period: args.period, + startDate: args.startDate, + endDate: args.endDate, + }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { + organization: { + include: { apiKeys: true }, + }, + }, + }) + + if (!user?.organization) { + throw new GraphQLError('Пользователь не привязан к организации') + } + + const apiKey = user.organization.apiKeys.find( + key => key.marketplace === 'WILDBERRIES' && key.isActive + ) + + if (!apiKey) { + return { + success: false, + message: 'API ключ Wildberries не найден', + data: [], + } + } + + const wbService = new WildberriesService(apiKey.apiKey) + const statistics = await wbService.getStatistics(args.startDate, args.endDate) + + console.log('✅ GET_WILDBERRIES_STATISTICS DOMAIN SUCCESS') + + return { + success: true, + message: 'Статистика получена', + data: statistics, + } + } catch (error: any) { + console.error('❌ GET_WILDBERRIES_STATISTICS DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка при получении статистики', + data: [], + } + } + }), + + // Получение статистики кампаний Wildberries + getWildberriesCampaignStats: withAuth(async ( + _: unknown, + args: { input: { campaigns: { id: number; dates?: string[]; interval?: { begin: string; end: string } }[] } }, + context: Context, + ) => { + console.log('🔍 GET_WILDBERRIES_CAMPAIGN_STATS DOMAIN QUERY STARTED:', { + userId: context.user?.id, + campaigns: args.input.campaigns.map(c => c.id), + }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { + organization: { + include: { apiKeys: true }, + }, + }, + }) + + if (!user?.organization) { + throw new GraphQLError('Пользователь не привязан к организации') + } + + const apiKey = user.organization.apiKeys.find( + key => key.marketplace === 'WILDBERRIES' && key.isActive + ) + + if (!apiKey) { + return { + success: false, + message: 'API ключ Wildberries не найден', + data: [], + } + } + + const wbService = new WildberriesService(apiKey.apiKey) + const stats = await wbService.getCampaignStats(args.input.campaigns) + + console.log('✅ GET_WILDBERRIES_CAMPAIGN_STATS DOMAIN SUCCESS') + + return { + success: true, + message: 'Статистика кампаний получена', + data: stats, + } + } catch (error: any) { + console.error('❌ GET_WILDBERRIES_CAMPAIGN_STATS DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка при получении статистики кампаний', + data: [], + } + } + }), + + // Получение списка кампаний Wildberries + getWildberriesCampaignsList: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 GET_WILDBERRIES_CAMPAIGNS_LIST DOMAIN QUERY STARTED:', { userId: context.user?.id }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { + organization: { + include: { apiKeys: true }, + }, + }, + }) + + if (!user?.organization) { + throw new GraphQLError('Пользователь не привязан к организации') + } + + const apiKey = user.organization.apiKeys.find( + key => key.marketplace === 'WILDBERRIES' && key.isActive + ) + + if (!apiKey) { + return { + success: false, + message: 'API ключ Wildberries не найден', + data: { adverts: [], all: 0 }, + } + } + + const wbService = new WildberriesService(apiKey.apiKey) + const campaignsList = await wbService.getCampaignsList() + + console.log('✅ GET_WILDBERRIES_CAMPAIGNS_LIST DOMAIN SUCCESS') + + return { + success: true, + message: 'Список кампаний получен', + data: campaignsList, + } + } catch (error: any) { + console.error('❌ GET_WILDBERRIES_CAMPAIGNS_LIST DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка при получении списка кампаний', + data: { adverts: [], all: 0 }, + } + } + }), + + // Возвратные претензии Wildberries + wbReturnClaims: withAuth(async (_: unknown, args: { isArchive: boolean; limit?: number; offset?: number }, context: Context) => { + console.log('🔍 WB_RETURN_CLAIMS DOMAIN QUERY STARTED:', { + userId: context.user?.id, + isArchive: args.isArchive, + limit: args.limit, + offset: args.offset, + }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { + organization: { + include: { apiKeys: true }, + }, + }, + }) + + if (!user?.organization) { + throw new GraphQLError('Пользователь не привязан к организации') + } + + const apiKey = user.organization.apiKeys.find( + key => key.marketplace === 'WILDBERRIES' && key.isActive + ) + + if (!apiKey) { + return { + claims: [], + total: 0, + } + } + + const wbService = new WildberriesService(apiKey.apiKey) + // TODO: Реализовать метод getReturnClaims в WildberriesService + const claims = [] + + console.log('✅ WB_RETURN_CLAIMS DOMAIN SUCCESS:', { + claimsCount: claims?.length || 0, + }) + + return { + claims: claims || [], + total: claims?.length || 0, + } + } catch (error: any) { + console.error('❌ WB_RETURN_CLAIMS DOMAIN ERROR:', error) + return { + claims: [], + total: 0, + } + } + }), + + // Получение данных о складах WB + getWBWarehouseData: withAuth(async (_: unknown, __: unknown, context: Context) => { + console.log('🔍 GET_WB_WAREHOUSE_DATA DOMAIN QUERY STARTED:', { userId: context.user?.id }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { + organization: { + include: { apiKeys: true }, + }, + }, + }) + + if (!user?.organization) { + throw new GraphQLError('Пользователь не привязан к организации') + } + + const apiKey = user.organization.apiKeys.find( + key => key.marketplace === 'WILDBERRIES' && key.isActive + ) + + if (!apiKey) { + return { + success: false, + message: 'API ключ Wildberries не найден', + cache: null, + fromCache: false, + } + } + + const wbService = new WildberriesService(apiKey.apiKey) + const warehouseData = await wbService.getWarehouses() + + console.log('✅ GET_WB_WAREHOUSE_DATA DOMAIN SUCCESS:', { + stocksCount: warehouseData?.stocks?.length || 0, + }) + + return { + success: true, + message: 'Данные о складах получены', + cache: warehouseData, + fromCache: false, + } + } catch (error: any) { + console.error('❌ GET_WB_WAREHOUSE_DATA DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка при получении данных о складах', + cache: null, + fromCache: false, + } + } + }), + }, + + Mutation: { + // Добавление API ключа маркетплейса + addMarketplaceApiKey: async ( + _: unknown, + args: { + input: { + marketplace: 'WILDBERRIES' | 'OZON' + apiKey: string + clientId?: string + validateOnly?: boolean + } + }, + context: Context, + ) => { + console.log('🔍 ADD_MARKETPLACE_API_KEY DOMAIN MUTATION STARTED:', { + marketplace: args.input.marketplace, + validateOnly: args.input.validateOnly, + hasUser: !!context.user, + }) + + // Разрешаем валидацию без авторизации + if (!args.input.validateOnly && !context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const { marketplace, apiKey, clientId, validateOnly } = args.input + + // Только валидация ключа + if (validateOnly) { + try { + const isValid = await marketplaceService.validateApiKey(marketplace, apiKey, clientId) + if (isValid) { + return { + success: true, + message: `API ключ ${marketplace} действителен`, + organization: null, + } + } else { + return { + success: false, + message: `Недействительный API ключ ${marketplace}`, + organization: null, + } + } + } catch (error: any) { + return { + success: false, + message: error.message || 'Ошибка при проверке API ключа', + organization: null, + } + } + } + + // Полное добавление ключа (требует авторизацию) + if (!context.user) { + throw new GraphQLError('Требуется авторизация') + } + + const user = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Пользователь не привязан к организации') + } + + try { + // Валидация ключа + const isValid = await marketplaceService.validateApiKey(marketplace, apiKey, clientId) + if (!isValid) { + return { + success: false, + message: `Недействительный API ключ ${marketplace}`, + organization: null, + } + } + + // Проверка существующего ключа + const existing = await prisma.apiKey.findUnique({ + where: { + organizationId_marketplace: { + organizationId: user.organization.id, + marketplace, + }, + }, + }) + + if (existing) { + // Обновляем существующий + await prisma.apiKey.update({ + where: { + organizationId_marketplace: { + organizationId: user.organization.id, + marketplace, + }, + }, + data: { + apiKey: apiKey, + isActive: true, + validationData: clientId ? { clientId, validatedAt: new Date() } : { validatedAt: new Date() }, + }, + }) + } else { + // Создаем новый + await prisma.apiKey.create({ + data: { + organizationId: user.organization.id, + marketplace, + apiKey: apiKey, + isActive: true, + validationData: clientId ? { clientId, validatedAt: new Date() } : { validatedAt: new Date() }, + }, + }) + } + + const updatedOrganization = await prisma.organization.findUnique({ + where: { id: user.organization.id }, + include: { apiKeys: true }, + }) + + console.log('✅ ADD_MARKETPLACE_API_KEY DOMAIN SUCCESS:', { + organizationId: user.organization.id, + marketplace, + }) + + return { + success: true, + message: `API ключ ${marketplace} успешно добавлен`, + organization: updatedOrganization, + } + } catch (error: any) { + console.error('❌ ADD_MARKETPLACE_API_KEY DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка при добавлении API ключа', + organization: null, + } + } + }, + + // Удаление API ключа маркетплейса + removeMarketplaceApiKey: withAuth(async ( + _: unknown, + args: { marketplace: 'WILDBERRIES' | 'OZON' }, + context: Context, + ) => { + console.log('🔍 REMOVE_MARKETPLACE_API_KEY DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + marketplace: args.marketplace, + }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Пользователь не привязан к организации') + } + + await prisma.apiKey.delete({ + where: { + organizationId_marketplace: { + organizationId: user.organization.id, + marketplace: args.marketplace, + }, + }, + }) + + const updatedOrganization = await prisma.organization.findUnique({ + where: { id: user.organization.id }, + include: { apiKeys: true }, + }) + + console.log('✅ REMOVE_MARKETPLACE_API_KEY DOMAIN SUCCESS:', { + organizationId: user.organization.id, + marketplace: args.marketplace, + }) + + return { + success: true, + message: `API ключ ${args.marketplace} успешно удален`, + organization: updatedOrganization, + } + } catch (error: any) { + console.error('❌ REMOVE_MARKETPLACE_API_KEY DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка при удалении API ключа', + organization: null, + } + } + }), + + // Создание поставки на Wildberries + createWildberriesSupply: withAuth(async ( + _: unknown, + args: { + input: { + cards: Array<{ + price: number + discountedPrice?: number + selectedQuantity: number + selectedServices?: string[] + }> + } + }, + context: Context, + ) => { + console.log('🔍 CREATE_WILDBERRIES_SUPPLY DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + cardsCount: args.input.cards.length, + }) + + try { + const currentUser = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { + organization: { + include: { apiKeys: true }, + }, + }, + }) + + if (!currentUser?.organization) { + throw new GraphQLError('У пользователя нет организации') + } + + // Проверка API ключа + const apiKey = currentUser.organization.apiKeys.find( + key => key.marketplace === 'WILDBERRIES' && key.isActive + ) + + if (!apiKey) { + return { + success: false, + message: 'Не найден активный API ключ Wildberries', + supply: null, + } + } + + // Создаем экземпляр WildberriesService с API ключом + const wbService = new WildberriesService(apiKey.apiKey) + + // Получаем каталог с карточками + const catalogData = await wbService.getProducts() + const catalog = catalogData?.data || [] + if (catalog.length === 0) { + return { + success: false, + message: 'Не удалось получить каталог товаров', + supply: null, + } + } + + // Создаем поставку + const supply = await prisma.wildberriesSupply.create({ + data: { + organizationId: currentUser.organization.id, + status: 'DRAFT', + totalCards: args.input.cards.length, + totalQuantity: args.input.cards.reduce((sum, card) => sum + card.selectedQuantity, 0), + cards: { + create: args.input.cards.map((card, index) => ({ + vendorCode: catalog[index]?.vendorCode || `ART${index}`, + title: catalog[index]?.title || `Товар ${index + 1}`, + selectedQuantity: card.selectedQuantity, + price: card.price, + discountedPrice: card.discountedPrice || card.price, + quantity: card.selectedQuantity, + warehouseId: catalog[index]?.skus?.[0]?.warehouses?.[0]?.warehouseId || 0, + services: card.selectedServices || [], + })), + }, + }, + include: { + organization: true, + cards: true, + }, + }) + + // TODO: Интеграция с WB API для создания поставки + // const wbSupplyId = await wbService.createSupply(...) + // Пока просто обновляем статус + await prisma.wildberriesSupply.update({ + where: { id: supply.id }, + data: { + status: 'CREATED', + }, + }) + + console.log('✅ CREATE_WILDBERRIES_SUPPLY DOMAIN SUCCESS:', { + supplyId: supply.id, + externalId: wbSupplyId, + }) + + return { + success: true, + message: 'Поставка успешно создана', + supply, + } + } catch (error: any) { + console.error('❌ CREATE_WILDBERRIES_SUPPLY DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка при создании поставки', + supply: null, + } + } + }), + + // Сохранение кеша данных складов WB + saveWBWarehouseCache: withAuth(async ( + _: unknown, + args: { + input: { + data: string; + totalProducts: number; + totalStocks: number; + totalReserved: number; + } + }, + context: Context, + ) => { + console.log('🔍 SAVE_WB_WAREHOUSE_CACHE DOMAIN MUTATION STARTED:', { + userId: context.user?.id, + hasData: !!args.input.data, + totalProducts: args.input.totalProducts, + totalStocks: args.input.totalStocks, + }) + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Пользователь не привязан к организации') + } + + // TODO: Реализовать кеширование в отдельной таблице или через Redis + console.log('✅ SAVE_WB_WAREHOUSE_CACHE DOMAIN SUCCESS:', { + organizationId: user.organization.id, + dataSize: args.input.data.length, + }) + + return { + success: true, + message: 'Кеш данных складов сохранен', + cache: { + id: `cache_${user.organization.id}_${Date.now()}`, + organizationId: user.organization.id, + cacheDate: new Date().toISOString(), + data: args.input.data, + totalProducts: args.input.totalProducts, + totalStocks: args.input.totalStocks, + totalReserved: args.input.totalReserved, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + fromCache: false, + } + } catch (error: any) { + console.error('❌ SAVE_WB_WAREHOUSE_CACHE DOMAIN ERROR:', error) + return { + success: false, + message: error.message || 'Ошибка при сохранении кеша', + cache: null, + fromCache: false, + } + } + }), + }, +} + +console.warn('🔥 WILDBERRIES DOMAIN МОДУЛЬ ЭКСПОРТЫ ГОТОВЫ') \ No newline at end of file diff --git a/src/graphql/resolvers/shared/api-keys.ts b/src/graphql/resolvers/shared/api-keys.ts new file mode 100644 index 0000000..2eb2f9f --- /dev/null +++ b/src/graphql/resolvers/shared/api-keys.ts @@ -0,0 +1,245 @@ +import { MarketplaceService, MarketplaceValidationResult } from '../../../services/marketplace-service' +import { prisma } from '../../../lib/prisma' + +// Типы для API ключей +export interface ApiKeyInput { + marketplace: 'WILDBERRIES' | 'OZON' + apiKey: string + clientId?: string // для Ozon +} + +export interface ApiKeyCreationResult { + success: boolean + message: string + apiKey?: { + id: string + marketplace: string + isActive: boolean + validationData: object | null + } +} + +// Shared utility для работы с API ключами в модульной архитектуре +export class ApiKeyUtility { + private marketplaceService: MarketplaceService + + constructor() { + this.marketplaceService = new MarketplaceService() + } + + /** + * Валидирует API ключ перед сохранением + */ + async validateApiKey(apiKeyInput: ApiKeyInput): Promise { + const { marketplace, apiKey, clientId } = apiKeyInput + + // Проверяем формат ключа + if (!this.marketplaceService.validateApiKeyFormat(marketplace, apiKey)) { + return { + isValid: false, + message: `Неверный формат API ключа для ${marketplace}`, + } + } + + // Валидируем ключ через API маркетплейса + return await this.marketplaceService.validateApiKey(marketplace, apiKey, clientId) + } + + /** + * Создает API ключ в базе данных после валидации + */ + async createApiKey( + organizationId: string, + apiKeyInput: ApiKeyInput, + ): Promise { + try { + console.warn('🔑 API_KEY_CREATION - НАЧАЛО:', { + organizationId, + marketplace: apiKeyInput.marketplace, + hasApiKey: !!apiKeyInput.apiKey, + hasClientId: !!apiKeyInput.clientId, + timestamp: new Date().toISOString(), + }) + + // Валидируем API ключ + const validationResult = await this.validateApiKey(apiKeyInput) + + if (!validationResult.isValid) { + console.warn('❌ API ключ не прошел валидацию:', validationResult.message) + return { + success: false, + message: validationResult.message, + } + } + + console.warn('✅ API ключ прошел валидацию:', { + marketplace: apiKeyInput.marketplace, + sellerId: validationResult.data?.sellerId, + sellerName: validationResult.data?.sellerName, + }) + + // Проверяем, нет ли уже API ключа для этого маркетплейса + const existingApiKey = await prisma.apiKey.findFirst({ + where: { + organizationId, + marketplace: apiKeyInput.marketplace, + }, + }) + + if (existingApiKey) { + console.warn('⚠️ API ключ для этого маркетплейса уже существует, обновляем') + + // Обновляем существующий ключ + const updatedApiKey = await prisma.apiKey.update({ + where: { id: existingApiKey.id }, + data: { + apiKey: apiKeyInput.apiKey, + clientId: apiKeyInput.clientId || null, + isActive: true, + validationData: validationResult.data ? JSON.stringify(validationResult.data) : null, + updatedAt: new Date(), + }, + }) + + console.warn('🔄 API ключ обновлен:', { + apiKeyId: updatedApiKey.id, + marketplace: updatedApiKey.marketplace, + isActive: updatedApiKey.isActive, + }) + + return { + success: true, + message: `API ключ ${apiKeyInput.marketplace} успешно обновлен`, + apiKey: { + id: updatedApiKey.id, + marketplace: updatedApiKey.marketplace, + isActive: updatedApiKey.isActive, + validationData: updatedApiKey.validationData + ? JSON.parse(updatedApiKey.validationData as string) + : null, + }, + } + } + + // Создаем новый API ключ + const newApiKey = await prisma.apiKey.create({ + data: { + organizationId, + marketplace: apiKeyInput.marketplace, + apiKey: apiKeyInput.apiKey, + clientId: apiKeyInput.clientId || null, + isActive: true, + validationData: validationResult.data ? JSON.stringify(validationResult.data) : null, + }, + }) + + console.warn('✅ API ключ создан:', { + apiKeyId: newApiKey.id, + organizationId: newApiKey.organizationId, + marketplace: newApiKey.marketplace, + isActive: newApiKey.isActive, + }) + + return { + success: true, + message: `API ключ ${apiKeyInput.marketplace} успешно добавлен`, + apiKey: { + id: newApiKey.id, + marketplace: newApiKey.marketplace, + isActive: newApiKey.isActive, + validationData: newApiKey.validationData + ? JSON.parse(newApiKey.validationData as string) + : null, + }, + } + } catch (error) { + console.error('🔴 Ошибка создания API ключа:', error) + return { + success: false, + message: 'Ошибка при сохранении API ключа', + } + } + } + + /** + * Создает несколько API ключей для организации + */ + async createMultipleApiKeys( + organizationId: string, + apiKeys: ApiKeyInput[], + ): Promise<{ success: boolean; results: ApiKeyCreationResult[]; sellerData?: any }> { + const results: ApiKeyCreationResult[] = [] + let primarySellerData: any = null + + console.warn('🔑 СОЗДАНИЕ МНОЖЕСТВЕННЫХ API КЛЮЧЕЙ:', { + organizationId, + count: apiKeys.length, + marketplaces: apiKeys.map(k => k.marketplace), + }) + + for (const apiKeyInput of apiKeys) { + const result = await this.createApiKey(organizationId, apiKeyInput) + results.push(result) + + // Сохраняем данные продавца из первого успешно проверенного ключа + if (result.success && result.apiKey?.validationData && !primarySellerData) { + primarySellerData = result.apiKey.validationData + console.warn('🏪 ДАННЫЕ ПРОДАВЦА ПОЛУЧЕНЫ:', { + marketplace: apiKeyInput.marketplace, + sellerName: primarySellerData.sellerName, + sellerId: primarySellerData.sellerId, + }) + } + } + + const successCount = results.filter(r => r.success).length + const success = successCount === apiKeys.length + + console.warn(`🏁 РЕЗУЛЬТАТ СОЗДАНИЯ API КЛЮЧЕЙ: ${successCount}/${apiKeys.length} успешно`) + + return { + success, + results, + sellerData: primarySellerData, + } + } + + /** + * Получает все активные API ключи организации + */ + async getOrganizationApiKeys(organizationId: string) { + return await prisma.apiKey.findMany({ + where: { + organizationId, + isActive: true, + }, + select: { + id: true, + marketplace: true, + isActive: true, + validationData: true, + createdAt: true, + updatedAt: true, + }, + }) + } + + /** + * Деактивирует API ключ + */ + async deactivateApiKey(apiKeyId: string): Promise { + try { + await prisma.apiKey.update({ + where: { id: apiKeyId }, + data: { isActive: false }, + }) + return true + } catch (error) { + console.error('Ошибка деактивации API ключа:', error) + return false + } + } +} + +// Экспортируем синглтон для использования в резолверах +export const apiKeyUtility = new ApiKeyUtility() \ No newline at end of file diff --git a/src/graphql/resolvers/shared/auth-utils.ts b/src/graphql/resolvers/shared/auth-utils.ts new file mode 100644 index 0000000..1fd7e5d --- /dev/null +++ b/src/graphql/resolvers/shared/auth-utils.ts @@ -0,0 +1,164 @@ +// Оптимизированные утилиты авторизации для устранения N+1 проблем +import { GraphQLError } from 'graphql' +import { Context } from '../../context' +import { prisma } from '../../../lib/prisma' + +// Кеш для пользователей в рамках одного запроса +const userCache = new Map() + +// Очистка кеша в начале каждого GraphQL запроса +export const clearUserCache = () => { + userCache.clear() +} + +/** + * Оптимизированное получение пользователя с кешированием + * Устраняет N+1 проблему при множественных проверках пользователя + */ +export const getCurrentUser = async (context: Context) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + const userId = context.user.id + + // Проверяем кеш + if (userCache.has(userId)) { + return userCache.get(userId) + } + + // Загружаем пользователя с организацией + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + organization: { + select: { + id: true, + name: true, + type: true, + fullName: true, + } + } + }, + }) + + if (!user) { + throw new GraphQLError('Пользователь не найден', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + if (!user.organization) { + throw new GraphQLError('Пользователь не привязан к организации', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + // Кешируем результат + userCache.set(userId, user) + + return user +} + +/** + * Проверка доступа к фулфилмент функциям + */ +export const requireFulfillmentAccess = async (context: Context) => { + const user = await getCurrentUser(context) + + if (user.organization?.type !== 'FULFILLMENT') { + throw new GraphQLError('Только фулфилмент может выполнять эти операции', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + return user +} + +/** + * Проверка доступа к поставщик функциям + */ +export const requireWholesaleAccess = async (context: Context) => { + const user = await getCurrentUser(context) + + if (user.organization?.type !== 'WHOLESALE') { + throw new GraphQLError('Только поставщики могут выполнять эти операции', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + return user +} + +/** + * Проверка доступа к селлер функциям + */ +export const requireSellerAccess = async (context: Context) => { + const user = await getCurrentUser(context) + + if (user.organization?.type !== 'SELLER') { + throw new GraphQLError('Только селлеры могут выполнять эти операции', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + return user +} + +/** + * Проверка доступа к логистика функциям + */ +export const requireLogisticsAccess = async (context: Context) => { + const user = await getCurrentUser(context) + + if (user.organization?.type !== 'LOGIST') { + throw new GraphQLError('Только логистика может выполнять эти операции', { + extensions: { code: 'FORBIDDEN' }, + }) + } + + return user +} + +/** + * Универсальная проверка типа организации + */ +export const requireOrganizationType = async (context: Context, types: string[]) => { + const user = await getCurrentUser(context) + + if (!types.includes(user.organization?.type || '')) { + throw new GraphQLError(`Операция доступна только для: ${types.join(', ')}`, { + extensions: { code: 'FORBIDDEN' }, + }) + } + + return user +} + +/** + * HOC для оборачивания резолверов с авторизацией + */ +export const withAuth = (resolver: (parent: any, args: any, context: Context) => Promise) => { + return async (parent: any, args: any, context: Context): Promise => { + // Проверяем базовую авторизацию + await getCurrentUser(context) + + // Вызываем исходный резолвер + return resolver(parent, args, context) + } +} + +/** + * HOC для резолверов с проверкой типа организации + */ +export const withOrgTypeAuth = ( + types: string[], + resolver: (parent: any, args: any, context: Context, user: any) => Promise +) => { + return async (parent: any, args: any, context: Context): Promise => { + const user = await requireOrganizationType(context, types) + return resolver(parent, args, context, user) + } +} \ No newline at end of file diff --git a/src/graphql/resolvers/shared/scalars.ts b/src/graphql/resolvers/shared/scalars.ts new file mode 100644 index 0000000..31f60fa --- /dev/null +++ b/src/graphql/resolvers/shared/scalars.ts @@ -0,0 +1,2 @@ +// Re-export scalars from the main graphql scalars file +export { JSONScalar, DateTimeScalar } from '../../scalars' \ No newline at end of file diff --git a/src/graphql/resolvers/shared/types.ts b/src/graphql/resolvers/shared/types.ts new file mode 100644 index 0000000..b1ba8b1 --- /dev/null +++ b/src/graphql/resolvers/shared/types.ts @@ -0,0 +1,6 @@ +// Type definitions for domain resolvers +export interface DomainResolvers { + Query?: Record + Mutation?: Record + [typeName: string]: Record | undefined +} \ No newline at end of file