import { prisma } from '../prisma' import { SearchType } from '../../generated/prisma' import { createToken, comparePasswords, hashPassword } from '../auth' import { createAuditLog, AuditAction, getClientInfo } from '../audit' import { uploadBuffer, generateFileKey } from '../s3' import { smsService } from '../sms-service' import { smsCodeStore } from '../sms-code-store' import { laximoService, laximoDocService, laximoUnitService } from '../laximo-service' import { autoEuroService } from '../autoeuro-service' import { yooKassaService } from '../yookassa-service' import { partsAPIService } from '../partsapi-service' import { partsIndexService } from '../partsindex-service' import { yandexDeliveryService, YandexPickupPoint, getAddressSuggestions } from '../yandex-delivery-service' import { InvoiceService } from '../invoice-service' import * as csvWriter from 'csv-writer' import * as XLSX from 'xlsx' import GraphQLJSON from 'graphql-type-json' interface CreateUserInput { firstName: string lastName: string email: string password: string avatar?: string role?: 'ADMIN' | 'MODERATOR' | 'USER' } interface LoginInput { email: string password: string } interface UpdateProfileInput { firstName?: string lastName?: string email?: string avatar?: string } interface UpdateUserInput { firstName?: string lastName?: string email?: string avatar?: string role?: 'ADMIN' | 'MODERATOR' | 'USER' } interface ChangePasswordInput { currentPassword: string newPassword: string } interface AdminChangePasswordInput { userId: string newPassword: string } interface Context { userId?: string clientId?: string userRole?: string userEmail?: string headers?: Headers } // Функция для сохранения истории поиска запчастей и автомобилей const saveSearchHistory = async ( context: Context, searchQuery: string, searchType: SearchType, brand?: string, articleNumber?: string, vehicleInfo?: { brand?: string; model?: string; year?: number }, resultCount: number = 0 ) => { try { if (!context.clientId) { return // Не сохраняем историю для неавторизованных пользователей } // Определяем clientId, убирая префикс client_ если он есть const clientIdParts = context.clientId.split('_') let clientId = context.clientId if (clientIdParts.length >= 3) { clientId = clientIdParts[1] // client_ID_timestamp -> ID } else if (clientIdParts.length === 2) { clientId = clientIdParts[1] // client_ID -> ID } // Проверяем существует ли клиент const client = await prisma.client.findUnique({ where: { id: clientId } }) if (!client) { console.log('saveSearchHistory: клиент не найден:', clientId) return } // Сохраняем в историю поиска await prisma.partsSearchHistory.create({ data: { clientId, searchQuery, searchType, brand, articleNumber, vehicleBrand: vehicleInfo?.brand, vehicleModel: vehicleInfo?.model, vehicleYear: vehicleInfo?.year, resultCount } }) console.log('✅ Сохранена запись в истории поиска:', { searchQuery, searchType, resultCount }) } catch (error) { console.error('❌ Ошибка сохранения истории поиска:', error) } } // Интерфейсы для каталога interface CategoryInput { name: string slug?: string description?: string seoTitle?: string seoDescription?: string image?: string isHidden?: boolean includeSubcategoryProducts?: boolean parentId?: string } interface ProductInput { name: string slug?: string article?: string description?: string videoUrl?: string wholesalePrice?: number retailPrice?: number weight?: number dimensions?: string unit?: string isVisible?: boolean applyDiscounts?: boolean stock?: number categoryIds?: string[] } interface ProductImageInput { url: string alt?: string order?: number } interface CharacteristicInput { name: string value: string } interface ProductOptionInput { name: string type: 'SINGLE' | 'MULTIPLE' values: OptionValueInput[] } interface OptionInput { name: string type: 'SINGLE' | 'MULTIPLE' values: OptionValueInput[] } interface OptionValueInput { value: string price?: number } // Интерфейсы для клиентов interface ClientInput { clientNumber?: string type: 'INDIVIDUAL' | 'LEGAL_ENTITY' name: string email?: string phone: string city?: string markup?: number isConfirmed?: boolean profileId?: string legalEntityType?: string inn?: string kpp?: string ogrn?: string okpo?: string legalAddress?: string actualAddress?: string bankAccount?: string bankName?: string bankBik?: string correspondentAccount?: string } interface ClientProfileInput { code?: string name: string description?: string baseMarkup: number autoSendInvoice?: boolean vinRequestModule?: boolean priceRangeMarkups?: ProfilePriceRangeMarkupInput[] orderDiscounts?: ProfileOrderDiscountInput[] supplierMarkups?: ProfileSupplierMarkupInput[] brandMarkups?: ProfileBrandMarkupInput[] categoryMarkups?: ProfileCategoryMarkupInput[] excludedBrands?: string[] excludedCategories?: string[] paymentTypes?: ProfilePaymentTypeInput[] } interface ProfilePriceRangeMarkupInput { priceFrom: number priceTo: number markupType: 'PERCENTAGE' | 'FIXED_AMOUNT' markupValue: number } interface ProfileOrderDiscountInput { minOrderSum: number discountType: 'PERCENTAGE' | 'FIXED_AMOUNT' discountValue: number } interface ProfileSupplierMarkupInput { supplierName: string markupType: 'PERCENTAGE' | 'FIXED_AMOUNT' markupValue: number } interface ProfileBrandMarkupInput { brandName: string markupType: 'PERCENTAGE' | 'FIXED_AMOUNT' markupValue: number } interface ProfileCategoryMarkupInput { categoryName: string markupType: 'PERCENTAGE' | 'FIXED_AMOUNT' markupValue: number } interface ProfilePaymentTypeInput { paymentType: 'CASH' | 'CARD' | 'BANK_TRANSFER' | 'ONLINE' | 'CREDIT' isEnabled: boolean } interface ClientVehicleInput { name: string vin?: string frame?: string licensePlate?: string brand?: string model?: string modification?: string year?: number mileage?: number comment?: string } interface ClientDeliveryAddressInput { name: string address: string deliveryType: 'COURIER' | 'PICKUP' | 'POST' | 'TRANSPORT' comment?: string // Дополнительные поля для курьерской доставки entrance?: string floor?: string apartment?: string intercom?: string deliveryTime?: string contactPhone?: string } interface ClientContactInput { phone?: string email?: string comment?: string } interface ClientContractInput { contractNumber: string contractDate?: Date name: string ourLegalEntity?: string clientLegalEntity?: string balance?: number currency?: string isActive?: boolean isDefault?: boolean contractType?: string relationship?: string paymentDelay?: boolean creditLimit?: number delayDays?: number fileUrl?: string } interface ClientLegalEntityInput { shortName: string fullName?: string form?: string legalAddress?: string actualAddress?: string taxSystem?: string responsiblePhone?: string responsiblePosition?: string responsibleName?: string accountant?: string signatory?: string registrationReasonCode?: string ogrn?: string inn: string vatPercent?: number } interface ClientBankDetailsInput { name: string accountNumber: string bankName: string bik: string correspondentAccount: string } interface ClientDiscountInput { name: string type: 'PERCENTAGE' | 'FIXED_AMOUNT' value: number isActive?: boolean validFrom?: Date validTo?: Date } interface ClientStatusInput { name: string color?: string description?: string } interface DiscountInput { name: string type: 'DISCOUNT' | 'PROMOCODE' code?: string minOrderAmount?: number discountType: 'PERCENTAGE' | 'FIXED_AMOUNT' discountValue: number isActive?: boolean validFrom?: Date validTo?: Date profileIds?: string[] } interface ClientFilterInput { type?: 'INDIVIDUAL' | 'LEGAL_ENTITY' registeredFrom?: Date registeredTo?: Date unconfirmed?: boolean vehicleSearch?: string profileId?: string } // Интерфейсы для заказов и платежей interface CreateOrderInput { clientId?: string clientEmail?: string clientPhone?: string clientName?: string items: OrderItemInput[] deliveryAddress?: string comment?: string paymentMethod?: string legalEntityId?: string } interface OrderItemInput { productId?: string externalId?: string name: string article?: string brand?: string price: number quantity: number } interface CreatePaymentInput { orderId: string returnUrl: string description?: string } interface FavoriteInput { productId?: string offerKey?: string name: string brand: string article: string price?: number currency?: string image?: string } // Утилиты const createSlug = (text: string): string => { return text .toLowerCase() .replace(/[а-я]/g, (char) => { const map: { [key: string]: string } = { 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya' } return map[char] || char }) .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') } const getCategoryLevel = async (categoryId: string, level = 0): Promise => { const category = await prisma.category.findUnique({ where: { id: categoryId }, select: { parentId: true } }) if (!category || !category.parentId) { return level } return getCategoryLevel(category.parentId, level + 1) } // Функция для расчета дней доставки из строки даты const calculateDeliveryDays = (deliveryDateStr: string): number => { if (!deliveryDateStr) return 0; try { const deliveryDate = new Date(deliveryDateStr); const today = new Date(); const diffTime = deliveryDate.getTime() - today.getTime(); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); return Math.max(0, diffDays); } catch (error) { return 0; } }; // Функция для получения контекста из глобальной переменной (больше не используется) function getContext(): Context { const context = (global as unknown as { __graphqlContext?: Context }).__graphqlContext || {} return context } export const resolvers = { DateTime: { serialize: (date: Date | string) => { if (typeof date === 'string') { return date; } if (date instanceof Date) { return date.toISOString(); } console.warn('DateTime serialize: неожиданный тип данных:', typeof date, date); return new Date(date).toISOString(); }, parseValue: (value: string) => new Date(value), parseLiteral: (ast: { value: string }) => new Date(ast.value), }, JSON: GraphQLJSON, Category: { level: async (parent: { id: string }) => { return await getCategoryLevel(parent.id) }, children: async (parent: { id: string }) => { return await prisma.category.findMany({ where: { parentId: parent.id }, orderBy: { name: 'asc' }, include: { _count: { select: { products: true } } } }) }, products: async (parent: { id: string }) => { return await prisma.product.findMany({ where: { categories: { some: { id: parent.id } } }, include: { images: { orderBy: { order: 'asc' } }, categories: true }, orderBy: { name: 'asc' } }) } }, Product: { categories: async (parent: { id: string }) => { const product = await prisma.product.findUnique({ where: { id: parent.id }, include: { categories: true } }) return product?.categories || [] }, images: async (parent: { id: string }) => { return await prisma.productImage.findMany({ where: { productId: parent.id }, orderBy: { order: 'asc' } }) }, options: async (parent: { id: string }) => { return await prisma.productOption.findMany({ where: { productId: parent.id }, include: { option: { include: { values: true } }, optionValue: true } }) }, characteristics: async (parent: { id: string }) => { return await prisma.productCharacteristic.findMany({ where: { productId: parent.id }, include: { characteristic: true } }) }, relatedProducts: async (parent: { id: string }) => { const product = await prisma.product.findUnique({ where: { id: parent.id }, include: { relatedProducts: { include: { images: { orderBy: { order: 'asc' } } } } } }) return product?.relatedProducts || [] }, accessoryProducts: async (parent: { id: string }) => { const product = await prisma.product.findUnique({ where: { id: parent.id }, include: { accessoryProducts: { include: { images: { orderBy: { order: 'asc' } } } } } }) return product?.accessoryProducts || [] } }, BalanceInvoice: { clientId: async (parent: { contract: { clientId: string } }) => { return parent.contract.clientId }, expiresAt: (parent: { expiresAt: Date }) => { return parent.expiresAt.toISOString() }, createdAt: (parent: { createdAt: Date }) => { return parent.createdAt.toISOString() }, updatedAt: (parent: { updatedAt: Date }) => { return parent.updatedAt.toISOString() } }, Query: { users: async () => { try { return await prisma.user.findMany({ orderBy: { createdAt: 'desc' } }) } catch (error) { console.error('Ошибка получения пользователей:', error) throw new Error('Не удалось получить список пользователей') } }, user: async (_: unknown, { id }: { id: string }) => { try { return await prisma.user.findUnique({ where: { id } }) } catch (error) { console.error('Ошибка получения пользователя:', error) throw new Error('Не удалось получить пользователя') } }, hasUsers: async () => { try { const count = await prisma.user.count() return count > 0 } catch (error) { console.error('Ошибка проверки пользователей:', error) throw new Error('Не удалось проверить наличие пользователей') } }, me: async (_: unknown, __: unknown, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } return await prisma.user.findUnique({ where: { id: context.userId } }) } catch (error) { console.error('Ошибка получения профиля:', error) throw new Error('Не удалось получить профиль пользователя') } }, // Счета на пополнение баланса balanceInvoices: async (_: unknown, __: unknown, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.userId) { throw new Error('Пользователь не авторизован') } const invoices = await prisma.balanceInvoice.findMany({ include: { contract: { include: { client: { include: { legalEntities: true } } } } }, orderBy: { createdAt: 'desc' } }) return invoices } catch (error) { console.error('Ошибка получения счетов:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось получить список счетов') } }, auditLogs: async (_: unknown, { limit = 50, offset = 0 }: { limit?: number; offset?: number }, context: Context) => { try { if (!context.userId || context.userRole !== 'ADMIN') { throw new Error('Недостаточно прав для просмотра логов аудита') } return await prisma.auditLog.findMany({ include: { user: true }, orderBy: { createdAt: 'desc' }, take: limit, skip: offset }) } catch (error) { console.error('Ошибка получения логов аудита:', error) throw new Error('Не удалось получить логи аудита') } }, auditLogsCount: async (_: unknown, __: unknown, context: Context) => { try { if (!context.userId || context.userRole !== 'ADMIN') { throw new Error('Недостаточно прав для просмотра логов аудита') } return await prisma.auditLog.count() } catch (error) { console.error('Ошибка подсчета логов аудита:', error) throw new Error('Не удалось подсчитать логи аудита') } }, // Каталог categories: async () => { try { return await prisma.category.findMany({ orderBy: { name: 'asc' }, include: { children: true, _count: { select: { products: true } } } }) } catch (error) { console.error('Ошибка получения категорий:', error) throw new Error('Не удалось получить категории') } }, category: async (_: unknown, { id }: { id: string }) => { try { return await prisma.category.findUnique({ where: { id }, include: { parent: true, children: true, products: { include: { images: { orderBy: { order: 'asc' } }, categories: true } }, _count: { select: { products: true } } } }) } catch (error) { console.error('Ошибка получения категории:', error) throw new Error('Не удалось получить категорию') } }, categoryBySlug: async (_: unknown, { slug }: { slug: string }) => { try { return await prisma.category.findUnique({ where: { slug }, include: { parent: true, children: true, products: { include: { images: { orderBy: { order: 'asc' } }, categories: true } }, _count: { select: { products: true } } } }) } catch (error) { console.error('Ошибка получения категории по slug:', error) throw new Error('Не удалось получить категорию') } }, products: async (_: unknown, { categoryId, search, limit = 50, offset = 0 }: { categoryId?: string; search?: string; limit?: number; offset?: number }) => { try { const where: Record = {} if (categoryId) { where.categories = { some: { id: categoryId } } } if (search) { where.OR = [ { name: { contains: search, mode: 'insensitive' } }, { article: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } } ] } return await prisma.product.findMany({ where, include: { images: { orderBy: { order: 'asc' } }, categories: true, characteristics: { include: { characteristic: true } } }, orderBy: { name: 'asc' }, take: limit, skip: offset }) } catch (error) { console.error('Ошибка получения товаров:', error) throw new Error('Не удалось получить товары') } }, productsCount: async (_: unknown, { categoryId, search }: { categoryId?: string; search?: string }) => { try { const where: Record = {} if (categoryId) { where.categories = { some: { id: categoryId } } } if (search) { where.OR = [ { name: { contains: search, mode: 'insensitive' } }, { article: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } } ] } return await prisma.product.count({ where }) } catch (error) { console.error('Ошибка подсчета товаров:', error) throw new Error('Не удалось подсчитать товары') } }, product: async (_: unknown, { id }: { id: string }) => { try { return await prisma.product.findUnique({ where: { id }, include: { categories: true, images: { orderBy: { order: 'asc' } }, options: { include: { option: { include: { values: true } }, optionValue: true } }, characteristics: { include: { characteristic: true } }, relatedProducts: { include: { images: { orderBy: { order: 'asc' } } } }, accessoryProducts: { include: { images: { orderBy: { order: 'asc' } } } } } }) } catch (error) { console.error('Ошибка получения товара:', error) throw new Error('Не удалось получить товар') } }, productBySlug: async (_: unknown, { slug }: { slug: string }) => { try { return await prisma.product.findUnique({ where: { slug }, include: { categories: true, images: { orderBy: { order: 'asc' } }, options: { include: { option: { include: { values: true } }, optionValue: true } }, characteristics: { include: { characteristic: true } }, relatedProducts: { include: { images: { orderBy: { order: 'asc' } } } }, accessoryProducts: { include: { images: { orderBy: { order: 'asc' } } } } } }) } catch (error) { console.error('Ошибка получения товара по slug:', error) throw new Error('Не удалось получить товар') } }, productHistory: async (_: unknown, { productId }: { productId: string }) => { try { return await prisma.productHistory.findMany({ where: { productId }, include: { user: true }, orderBy: { createdAt: 'desc' } }) } catch (error) { console.error('Ошибка получения истории товара:', error) throw new Error('Не удалось получить историю товара') } }, options: async () => { try { return await prisma.option.findMany({ include: { values: true }, orderBy: { name: 'asc' } }) } catch (error) { console.error('Ошибка получения опций:', error) throw new Error('Не удалось получить опции') } }, characteristics: async () => { try { return await prisma.characteristic.findMany({ orderBy: { name: 'asc' } }) } catch (error) { console.error('Ошибка получения характеристик:', error) throw new Error('Не удалось получить характеристики') } }, // Клиенты clients: async (_: unknown, { filter, search, limit = 50, offset = 0, sortBy = 'createdAt', sortOrder = 'desc' }: { filter?: ClientFilterInput; search?: string; limit?: number; offset?: number; sortBy?: string; sortOrder?: string }) => { try { const where: Record = {} if (filter) { if (filter.type) { where.type = filter.type } if (filter.registeredFrom || filter.registeredTo) { // eslint-disable-next-line @typescript-eslint/no-explicit-any where.createdAt = {} as any if (filter.registeredFrom) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (where.createdAt as any).gte = filter.registeredFrom } if (filter.registeredTo) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (where.createdAt as any).lte = filter.registeredTo } } if (filter.unconfirmed) { where.isConfirmed = false } if (filter.profileId) { where.profileId = filter.profileId } if (filter.vehicleSearch) { where.vehicles = { some: { OR: [ { vin: { contains: filter.vehicleSearch, mode: 'insensitive' } }, { frame: { contains: filter.vehicleSearch, mode: 'insensitive' } }, { licensePlate: { contains: filter.vehicleSearch, mode: 'insensitive' } } ] } } } } if (search) { where.OR = [ { name: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } }, { phone: { contains: search, mode: 'insensitive' } }, { clientNumber: { contains: search, mode: 'insensitive' } } ] } const orderBy: Record = {} orderBy[sortBy] = sortOrder return await prisma.client.findMany({ where, include: { profile: true, vehicles: true, discounts: true }, orderBy, take: limit, skip: offset }) } catch (error) { console.error('Ошибка получения клиентов:', error) throw new Error('Не удалось получить клиентов') } }, client: async (_: unknown, { id }: { id: string }) => { try { return await prisma.client.findUnique({ where: { id }, include: { profile: true, manager: true, vehicles: true, discounts: true, deliveryAddresses: true, contacts: true, contracts: true, legalEntities: { include: { bankDetails: true } }, bankDetails: { include: { legalEntity: true } }, balanceHistory: { include: { user: true }, orderBy: { createdAt: 'desc' } } } }) } catch (error) { console.error('Ошибка получения клиента:', error) throw new Error('Не удалось получить клиента') } }, clientsCount: async (_: unknown, { filter, search }: { filter?: ClientFilterInput; search?: string }) => { try { const where: Record = {} if (filter) { if (filter.type) { where.type = filter.type } if (filter.registeredFrom || filter.registeredTo) { // eslint-disable-next-line @typescript-eslint/no-explicit-any where.createdAt = {} as any if (filter.registeredFrom) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (where.createdAt as any).gte = filter.registeredFrom } if (filter.registeredTo) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (where.createdAt as any).lte = filter.registeredTo } } if (filter.unconfirmed) { where.isConfirmed = false } if (filter.profileId) { where.profileId = filter.profileId } if (filter.vehicleSearch) { where.vehicles = { some: { OR: [ { vin: { contains: filter.vehicleSearch, mode: 'insensitive' } }, { frame: { contains: filter.vehicleSearch, mode: 'insensitive' } }, { licensePlate: { contains: filter.vehicleSearch, mode: 'insensitive' } } ] } } } } if (search) { where.OR = [ { name: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } }, { phone: { contains: search, mode: 'insensitive' } }, { clientNumber: { contains: search, mode: 'insensitive' } } ] } return await prisma.client.count({ where }) } catch (error) { console.error('Ошибка подсчета клиентов:', error) throw new Error('Не удалось подсчитать клиентов') } }, // Запросы для гаража клиентов userVehicles: async () => { try { const context = getContext() if (!context.clientId) { throw new Error('Клиент не авторизован') } // Проверяем существует ли клиент, если нет - создаем только для временных клиентов let client = await prisma.client.findUnique({ where: { id: context.clientId } }) if (!client) { if (context.clientId.startsWith('client_') && context.clientId.length > 30) { client = await prisma.client.create({ data: { id: context.clientId, clientNumber: `CLIENT_${Date.now()}`, type: 'INDIVIDUAL', name: 'Гость', phone: '+7', isConfirmed: false } }) } else { throw new Error('Клиент не найден в системе') } } return await prisma.clientVehicle.findMany({ where: { clientId: context.clientId }, orderBy: { createdAt: 'desc' } }) } catch (error) { console.error('Ошибка получения автомобилей:', error) throw new Error('Не удалось получить автомобили') } }, // Получение данных авторизованного клиента clientMe: async () => { try { const context = getContext() console.log('clientMe резолвер: контекст:', context) if (!context.clientId) { console.log('clientMe резолвер: clientId отсутствует') throw new Error('Клиент не авторизован') } console.log('clientMe резолвер: получаем данные для clientId:', context.clientId) const client = await prisma.client.findUnique({ where: { id: context.clientId }, include: { legalEntities: { include: { bankDetails: { include: { legalEntity: true } } } }, profile: true, vehicles: true, deliveryAddresses: true, contacts: true, contracts: true, bankDetails: { include: { legalEntity: true } }, discounts: true } }) console.log('clientMe резолвер: найден клиент:', client ? client.id : 'null') // Принудительно заменяем null bankDetails на пустые массивы if (client && client.legalEntities) { client.legalEntities = client.legalEntities.map(entity => ({ ...entity, bankDetails: entity.bankDetails || [] })) } return client } catch (error) { console.error('Ошибка получения данных клиента:', error) throw new Error('Не удалось получить данные клиента') } }, // Получение избранного авторизованного клиента favorites: async (_: unknown, _args: unknown, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { // Для неавторизованных пользователей возвращаем пустой массив return [] } // Удаляем префикс client_ если он есть const cleanClientId = actualContext.clientId.startsWith('client_') ? actualContext.clientId.substring(7) : actualContext.clientId const favorites = await prisma.favorite.findMany({ where: { clientId: cleanClientId }, orderBy: { createdAt: 'desc' }, include: { client: true } }) return favorites } catch (error) { console.error('Ошибка получения избранного:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось получить избранное') } }, vehicleSearchHistory: async (_: unknown, args: unknown, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { // Для неавторизованных пользователей возвращаем пустую историю return [] } // Определяем clientId, убирая префикс client_ если он есть const clientIdParts = actualContext.clientId.split('_') let clientId = actualContext.clientId if (clientIdParts.length >= 3) { clientId = clientIdParts[1] // client_ID_timestamp -> ID } else if (clientIdParts.length === 2) { clientId = clientIdParts[1] // client_ID -> ID } console.log('vehicleSearchHistory: получение VIN истории для клиента:', clientId) // Проверяем существует ли клиент const client = await prisma.client.findUnique({ where: { id: clientId } }) if (!client) { console.log('vehicleSearchHistory: клиент не найден:', clientId) return [] } // Получаем записи истории только с типом VIN const vinHistoryItems = await prisma.partsSearchHistory.findMany({ where: { clientId, searchType: 'VIN' // Фильтруем только VIN запросы }, orderBy: { createdAt: 'desc' }, take: 20 // Ограничиваем количество записей }) console.log(`vehicleSearchHistory: найдено ${vinHistoryItems.length} VIN записей`) // Преобразуем данные в формат VehicleSearchHistory const historyItems = vinHistoryItems.map(item => ({ id: item.id, vin: item.searchQuery, // VIN записан в searchQuery brand: item.vehicleBrand || item.brand, model: item.vehicleModel, searchDate: item.createdAt instanceof Date ? item.createdAt.toISOString() : item.createdAt, searchQuery: item.searchQuery })) return historyItems } catch (error) { console.error('Ошибка получения истории VIN поиска:', error) throw new Error('Не удалось получить историю поиска') } }, // История поиска запчастей partsSearchHistory: async (_: unknown, { limit = 50, offset = 0 }: { limit?: number; offset?: number }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { // Для неавторизованных пользователей возвращаем пустую историю return { items: [], total: 0, hasMore: false } } // Определяем clientId, убирая префикс client_ если он есть const clientIdParts = actualContext.clientId.split('_') let clientId = actualContext.clientId if (clientIdParts.length >= 3) { clientId = clientIdParts[1] // client_ID_timestamp -> ID } else if (clientIdParts.length === 2) { clientId = clientIdParts[1] // client_ID -> ID } console.log('partsSearchHistory: получение истории для клиента:', clientId) console.log('prisma.partsSearchHistory:', typeof prisma.partsSearchHistory) // Проверяем существует ли клиент const client = await prisma.client.findUnique({ where: { id: clientId } }) if (!client) { console.log('partsSearchHistory: клиент не найден:', clientId) return { items: [], total: 0, hasMore: false } } // Проверяем, что Prisma Client правильно инициализирован if (!prisma.partsSearchHistory) { console.error('prisma.partsSearchHistory не определен') throw new Error('Ошибка инициализации базы данных') } // Получаем общее количество записей const total = await prisma.partsSearchHistory.count({ where: { clientId } }) // Получаем записи истории const historyItems = await prisma.partsSearchHistory.findMany({ where: { clientId }, orderBy: { createdAt: 'desc' }, take: limit, skip: offset }) console.log(`partsSearchHistory: найдено ${historyItems.length} записей`) const items = historyItems.map(item => ({ id: item.id, searchQuery: item.searchQuery, searchType: item.searchType, brand: item.brand, articleNumber: item.articleNumber, vehicleInfo: item.vehicleBrand || item.vehicleModel || item.vehicleYear ? { brand: item.vehicleBrand, model: item.vehicleModel, year: item.vehicleYear } : null, resultCount: item.resultCount, createdAt: item.createdAt instanceof Date ? item.createdAt.toISOString() : item.createdAt })) return { items, total, hasMore: offset + limit < total } } catch (error) { console.error('Ошибка получения истории поиска запчастей:', error) throw new Error('Не удалось получить историю поиска запчастей') } }, searchVehicleByVin: async (_: unknown, { vin }: { vin: string }) => { try { // Временная заглушка - возвращаем объект с переданным VIN // В будущем здесь будет реальная логика поиска по VIN return { vin, brand: null, model: null, modification: null, year: null, bodyType: null, engine: null, transmission: null, drive: null, fuel: null } } catch (error) { console.error('Ошибка поиска по VIN:', error) throw new Error('Не удалось найти автомобиль по VIN') } }, clientProfiles: async () => { try { return await prisma.clientProfile.findMany({ orderBy: { name: 'asc' }, include: { priceRangeMarkups: true, orderDiscounts: true, supplierMarkups: true, brandMarkups: true, categoryMarkups: true, excludedBrands: true, excludedCategories: true, paymentTypes: true, _count: { select: { clients: true } } } }) } catch (error) { console.error('Ошибка получения профилей клиентов:', error) throw new Error('Не удалось получить профили клиентов') } }, clientProfile: async (_: unknown, { id }: { id: string }) => { try { return await prisma.clientProfile.findUnique({ where: { id }, include: { clients: true, priceRangeMarkups: true, orderDiscounts: true, supplierMarkups: true, brandMarkups: true, categoryMarkups: true, excludedBrands: true, excludedCategories: true, paymentTypes: true, _count: { select: { clients: true } } } }) } catch (error) { console.error('Ошибка получения профиля клиента:', error) throw new Error('Не удалось получить профиль клиента') } }, clientStatuses: async () => { try { return await prisma.clientStatus.findMany({ orderBy: { name: 'asc' } }) } catch (error) { console.error('Ошибка получения статусов клиентов:', error) throw new Error('Не удалось получить статусы клиентов') } }, clientStatus: async (_: unknown, { id }: { id: string }) => { try { return await prisma.clientStatus.findUnique({ where: { id } }) } catch (error) { console.error('Ошибка получения статуса клиента:', error) throw new Error('Не удалось получить статус клиента') } }, // Скидки и промокоды discounts: async () => { try { return await prisma.discount.findMany({ orderBy: { name: 'asc' }, include: { profiles: { include: { profile: true } } } }) } catch (error) { console.error('Ошибка получения скидок:', error) throw new Error('Не удалось получить скидки') } }, discount: async (_: unknown, { id }: { id: string }) => { try { return await prisma.discount.findUnique({ where: { id }, include: { profiles: { include: { profile: true } } } }) } catch (error) { console.error('Ошибка получения скидки:', error) throw new Error('Не удалось получить скидку') } }, // Laximo интеграция laximoBrands: async () => { return await laximoService.getListCatalogs() }, laximoCatalogInfo: async (_: unknown, { catalogCode }: { catalogCode: string }) => { try { console.log('🔍 Запрос информации о каталоге:', catalogCode) const result = await laximoService.getCatalogInfo(catalogCode) console.log('📋 Результат getCatalogInfo:', result ? 'найден' : 'не найден') return result } catch (error) { console.error('❌ Ошибка получения информации о каталоге:', error) return null } }, laximoWizard2: async (_: unknown, { catalogCode, ssd }: { catalogCode: string; ssd?: string }) => { try { return await laximoService.getWizard2(catalogCode, ssd || '') } catch (error) { console.error('Ошибка получения параметров wizard:', error) return [] } }, laximoFindVehicle: async (_: unknown, { catalogCode, vin }: { catalogCode: string; vin: string }, context: Context) => { try { // Если catalogCode пустой, делаем глобальный поиск if (!catalogCode || catalogCode.trim() === '') { console.log('🌍 Глобальный поиск автомобиля по VIN/Frame:', vin) const result = await laximoService.findVehicleGlobal(vin) // Сохраняем в историю поиска с информацией о первом найденном автомобиле let vehicleInfo: { brand?: string; model?: string; year?: number } | undefined = undefined if (result && result.length > 0) { const firstVehicle = result[0] vehicleInfo = { brand: firstVehicle.brand, model: firstVehicle.model, year: firstVehicle.year ? parseInt(firstVehicle.year, 10) : undefined } } await saveSearchHistory( context, vin, 'VIN', undefined, undefined, vehicleInfo, result.length ) return result } const result = await laximoService.findVehicle(catalogCode, vin) // Сохраняем в историю поиска с информацией о первом найденном автомобиле let vehicleInfo: { brand?: string; model?: string; year?: number } | undefined = undefined if (result && result.length > 0) { const firstVehicle = result[0] vehicleInfo = { brand: firstVehicle.brand, model: firstVehicle.model, year: firstVehicle.year ? parseInt(firstVehicle.year, 10) : undefined } } await saveSearchHistory( context, vin, 'VIN', catalogCode, undefined, vehicleInfo, result.length ) return result } catch (error) { console.error('Ошибка поиска автомобиля по VIN:', error) return [] } }, laximoFindVehicleByWizard: async (_: unknown, { catalogCode, ssd }: { catalogCode: string; ssd: string }, context: Context) => { try { const result = await laximoService.findVehicleByWizard(catalogCode, ssd) // Сохраняем в историю поиска await saveSearchHistory( context, `Поиск по параметрам в ${catalogCode}`, 'WIZARD', catalogCode, undefined, undefined, result.length ) return result } catch (error) { console.error('Ошибка поиска автомобиля по wizard:', error) return [] } }, laximoFindVehicleByPlate: async (_: unknown, { catalogCode, plateNumber }: { catalogCode: string; plateNumber: string }, context: Context) => { try { const result = await laximoService.findVehicleByPlateNumber(catalogCode, plateNumber) // Сохраняем в историю поиска с информацией о первом найденном автомобиле let vehicleInfo: { brand?: string; model?: string; year?: number } | undefined = undefined if (result && result.length > 0) { const firstVehicle = result[0] vehicleInfo = { brand: firstVehicle.brand, model: firstVehicle.model, year: firstVehicle.year ? parseInt(firstVehicle.year, 10) : undefined } } await saveSearchHistory( context, plateNumber, 'PLATE', catalogCode, undefined, vehicleInfo, result.length ) return result } catch (error) { console.error('Ошибка поиска автомобиля по госномеру:', error) return [] } }, laximoFindVehicleByPlateGlobal: async (_: unknown, { plateNumber }: { plateNumber: string }, context: Context) => { try { console.log('🔍 GraphQL Resolver - Глобальный поиск автомобиля по госномеру:', plateNumber) const result = await laximoService.findVehicleByPlateNumberGlobal(plateNumber) console.log('📋 Результат глобального поиска по госномеру:', result ? `найдено ${result.length} автомобилей` : 'результат пустой') // Сохраняем в историю поиска с информацией о первом найденном автомобиле let vehicleInfo: { brand?: string; model?: string; year?: number } | undefined = undefined if (result && result.length > 0) { const firstVehicle = result[0] vehicleInfo = { brand: firstVehicle.brand, model: firstVehicle.model, year: firstVehicle.year ? parseInt(firstVehicle.year, 10) : undefined } } await saveSearchHistory( context, plateNumber, 'PLATE', undefined, undefined, vehicleInfo, result.length ) return result } catch (error) { console.error('❌ Ошибка глобального поиска автомобиля по госномеру:', error) return [] } }, laximoFindPartReferences: async (_: unknown, { partNumber }: { partNumber: string }) => { try { return await laximoService.findPartReferences(partNumber) } catch (error) { console.error('Ошибка поиска каталогов по артикулу:', error) return [] } }, laximoFindApplicableVehicles: async (_: unknown, { catalogCode, partNumber }: { catalogCode: string; partNumber: string }, context: Context) => { try { const result = await laximoService.findApplicableVehicles(catalogCode, partNumber) // Сохраняем в историю поиска await saveSearchHistory( context, partNumber, 'PART_VEHICLES', catalogCode, partNumber, undefined, result.length ) return result } catch (error) { console.error('Ошибка поиска автомобилей по артикулу:', error) return [] } }, laximoFindVehiclesByPartNumber: async (_: unknown, { partNumber }: { partNumber: string }) => { try { console.log('🔍 GraphQL Resolver - Комплексный поиск автомобилей по артикулу:', partNumber) const result = await laximoService.findVehiclesByPartNumber(partNumber) console.log('📋 Результат комплексного поиска:', result ? `найдено ${result.totalVehicles} автомобилей в ${result.catalogs.length} каталогах` : 'результат null') return result } catch (error) { console.error('❌ Ошибка комплексного поиска автомобилей по артикулу:', error) return { partNumber, catalogs: [], totalVehicles: 0 } } }, laximoVehicleInfo: async (_: unknown, { catalogCode, vehicleId, ssd, localized }: { catalogCode: string; vehicleId: string; ssd?: string; localized: boolean }) => { try { console.log('🔍 GraphQL laximoVehicleInfo resolver - входные параметры:', { catalogCode, vehicleId, ssd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует', localized, ssdLength: ssd?.length }) const result = await laximoService.getVehicleInfo(catalogCode, vehicleId, ssd, localized) console.log('📋 GraphQL laximoVehicleInfo resolver - результат:', { inputVehicleId: vehicleId, returnedVehicleId: result?.vehicleid, vehicleName: result?.name, brand: result?.brand, catalog: result?.catalog, hasResult: !!result, vehicleIdChanged: result?.vehicleid !== vehicleId }) if (result && result.vehicleid !== vehicleId) { console.log('🚨 BACKEND: Vehicle ID изменился!') console.log(`📍 Запрошенный: ${vehicleId}`) console.log(`📍 Полученный: ${result.vehicleid}`) console.log(`📍 SSD: ${ssd?.substring(0, 50)}...`) } return result } catch (error) { console.error('❌ Ошибка получения информации об автомобиле:', error) return null } }, laximoQuickGroups: async (_: unknown, { catalogCode, vehicleId, ssd }: { catalogCode: string; vehicleId: string; ssd?: string }) => { try { console.log('🔧 GraphQL Resolver - получение групп быстрого поиска:', { catalogCode, vehicleId, ssd: ssd?.substring(0, 30) }) let groups: any[] = [] // Сначала пробуем стандартный метод getListQuickGroup try { groups = await laximoService.getListQuickGroup(catalogCode, vehicleId, ssd) console.log('✅ Получено групп через getListQuickGroup:', groups.length) } catch (quickGroupError) { console.warn('⚠️ Ошибка getListQuickGroup:', quickGroupError) // Альтернативный метод - используем ListCategories try { console.log('🔄 Пробуем альтернативный метод - ListCategories') groups = await laximoService.getListCategories(catalogCode, vehicleId, ssd) console.log('✅ Получено категорий через getListCategories:', groups.length) } catch (categoriesError) { console.warn('⚠️ Ошибка getListCategories:', categoriesError) } } console.log('🎯 GraphQL Resolver - итоговый результат:') console.log('📊 Общее количество групп:', groups.length) if (groups.length > 0) { console.log('📋 Первые 5 групп:') groups.slice(0, 5).forEach((group, index) => { console.log(` ${index + 1}. ${group.name} (ID: ${group.quickgroupid}, link: ${group.link})`) }) } // Подсчитываем детали в подгруппах groups.forEach((group, index) => { const countChildren = (g: any): number => { let count = 1 if (g.children && g.children.length > 0) { g.children.forEach((child: any) => { count += countChildren(child) }) } return count } const totalChildren = countChildren(group) - 1 // Исключаем саму группу console.log(`📂 Группа ${index + 1}: ${group.name} - всего подэлементов: ${totalChildren}`) if (group.children && group.children.length > 0) { group.children.forEach((child, childIndex) => { console.log(` └─ Дочерняя группа ${childIndex + 1}:`, { quickgroupid: child.quickgroupid, name: child.name, link: child.link, code: child.code || 'отсутствует', children: child.children?.length || 0 }) }) } }) return groups } catch (error) { console.error('❌ Ошибка получения групп быстрого поиска:', error) console.error('❌ Stack trace:', error instanceof Error ? error.stack : 'нет stack trace') return [] } }, laximoQuickGroupsWithXML: async (_: unknown, { catalogCode, vehicleId, ssd }: { catalogCode: string; vehicleId: string; ssd?: string }) => { try { console.log('🔧 GraphQL Resolver - получение групп быстрого поиска с RAW XML:', { catalogCode, vehicleId, ssd: ssd?.substring(0, 30) }) const result = await laximoService.getListQuickGroupWithXML(catalogCode, vehicleId, ssd) console.log('🎯 GraphQL Resolver - результат от LaximoService:') console.log('📊 Общее количество групп:', result.groups.length) console.log('📄 RAW XML длина:', result.rawXML.length) return { groups: result.groups, rawXML: result.rawXML } } catch (error) { console.error('❌ Ошибка получения групп быстрого поиска с XML:', error) return { groups: [], rawXML: '' } } }, laximoCategories: async (_: unknown, { catalogCode, vehicleId, ssd }: { catalogCode: string; vehicleId?: string; ssd?: string }) => { try { console.log('🔍 Запрос категорий каталога:', catalogCode, 'vehicleId:', vehicleId, 'ssd:', ssd ? `${ssd.substring(0, 30)}...` : 'отсутствует') return await laximoService.getListCategories(catalogCode, vehicleId, ssd) } catch (error) { console.error('Ошибка получения категорий каталога:', error) return [] } }, laximoUnits: async (_: unknown, { catalogCode, vehicleId, ssd, categoryId }: { catalogCode: string; vehicleId?: string; ssd?: string; categoryId?: string }) => { try { console.log('🔍 GraphQL Resolver - запрос узлов каталога:', { catalogCode, vehicleId, categoryId, hasSSD: !!ssd, ssdLength: ssd?.length }) let result: any[] = [] // Если есть categoryId, то мы ищем узлы в конкретной категории if (categoryId) { console.log('🔧 Поиск узлов в категории:', categoryId) // Для обычных категорий НЕ используем SSD - он нужен только для быстрых групп try { console.log('🔧 Пробуем ListUnits БЕЗ SSD для обычной категории...') result = await laximoService.getListUnits(catalogCode, vehicleId, undefined, categoryId) console.log('✅ Получено узлов в категории:', result.length) } catch (error: any) { console.log('⚠️ Ошибка ListUnits без SSD:', error.message) // Если и без SSD не работает, пробуем получить категории вместо узлов try { console.log('🔧 Пробуем получить подкатегории...') result = await laximoService.getListCategories(catalogCode, vehicleId, undefined) // Фильтруем только подкатегории данной категории, если есть parent-child связи console.log('✅ Получено подкатегорий:', result.length) } catch (categoriesError: any) { console.log('⚠️ Ошибка получения подкатегорий:', categoriesError.message) } } } else { // Если categoryId нет, получаем список всех категорий console.log('🔧 Получаем список всех категорий...') try { result = await laximoService.getListCategories(catalogCode, vehicleId, ssd) // Если получили категории, используем SSD из первой категории для получения узлов if (result.length > 0 && result[0].ssd) { console.log('🔧 Найден SSD в категориях, пробуем получить узлы...') const categorySsd = result[0].ssd console.log('🔑 SSD из категории:', categorySsd.substring(0, 30) + '...') // Пробуем получить узлы для первой категории с найденным SSD try { const unitsResult = await laximoService.getListUnits(catalogCode, vehicleId, categorySsd, result[0].quickgroupid) if (unitsResult.length > 0) { console.log('✅ Получены узлы с SSD из категории:', unitsResult.length) result = unitsResult } } catch (error: any) { console.log('⚠️ Ошибка получения узлов с SSD из категории:', error.message) } } } catch (error: any) { console.log('⚠️ Ошибка ListCategories:', error.message) // Пробуем без SSD if (ssd) { console.log('🔧 Пробуем ListCategories без SSD...') result = await laximoService.getListCategories(catalogCode, vehicleId, undefined) } } } console.log('✅ GraphQL Resolver - получено узлов каталога:', result?.length || 0) if (result && result.length > 0) { console.log('📦 Первый узел:', { quickgroupid: result[0].quickgroupid, name: result[0].name, code: result[0].code, hasImageUrl: !!result[0].imageurl, imageUrl: result[0].imageurl ? result[0].imageurl.substring(0, 80) + '...' : 'отсутствует' }) } return result || [] } catch (error) { console.error('❌ GraphQL Resolver - ошибка получения узлов каталога:', error) return [] } }, laximoQuickDetail: async (_: unknown, { catalogCode, vehicleId, quickGroupId, ssd }: { catalogCode: string; vehicleId: string; quickGroupId: string; ssd: string }) => { try { console.log('🔍 Запрос деталей группы быстрого поиска - RAW PARAMS:', { catalogCode: catalogCode, catalogCodeType: typeof catalogCode, catalogCodeLength: catalogCode?.length, vehicleId: vehicleId, vehicleIdType: typeof vehicleId, vehicleIdLength: vehicleId?.length, quickGroupId: quickGroupId, quickGroupIdType: typeof quickGroupId, quickGroupIdLength: quickGroupId?.length, ssd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует', ssdType: typeof ssd, ssdLength: ssd?.length }) // Валидация параметров с детальными логами console.log('🔍 Проверка catalogCode:', { catalogCode, isEmpty: !catalogCode, isTrimEmpty: catalogCode?.trim() === '' }) if (!catalogCode || catalogCode.trim() === '') { console.error('❌ Пустой catalogCode:', catalogCode) throw new Error(`Пустой код каталога: "${catalogCode}"`) } console.log('🔍 Проверка vehicleId:', { vehicleId, isUndefined: vehicleId === undefined, isNull: vehicleId === null, isEmpty: vehicleId === '' }) if (vehicleId === undefined || vehicleId === null) { console.error('❌ Пустой vehicleId:', vehicleId) throw new Error(`Пустой ID автомобиля: "${vehicleId}"`) } console.log('🔍 Проверка quickGroupId:', { quickGroupId, isEmpty: !quickGroupId, isTrimEmpty: quickGroupId?.trim() === '' }) if (!quickGroupId || quickGroupId.trim() === '') { console.error('❌ Пустой quickGroupId:', quickGroupId) throw new Error(`Пустой ID группы: "${quickGroupId}"`) } console.log('🔍 Проверка ssd:', { ssd: ssd ? `${ssd.substring(0, 30)}...` : ssd, isEmpty: !ssd, isTrimEmpty: ssd?.trim() === '' }) if (!ssd || ssd.trim() === '') { console.error('❌ Пустой ssd:', ssd) throw new Error(`Пустой SSD: "${ssd}"`) } console.log('✅ Все параметры валидны, вызываем laximoService.getListQuickDetail') const result = await laximoService.getListQuickDetail(catalogCode, vehicleId, quickGroupId, ssd) console.log('✅ Результат от laximoService:', result ? 'получен' : 'null') return result } catch (error) { console.error('❌ Ошибка получения деталей группы быстрого поиска:', error) throw error // Пробрасываем ошибку наверх } }, laximoOEMSearch: async (_: unknown, { catalogCode, vehicleId, oemNumber, ssd }: { catalogCode: string; vehicleId: string; oemNumber: string; ssd: string }) => { try { console.log('🔍 Поиск детали по OEM номеру:', { catalogCode, vehicleId, oemNumber }) return await laximoService.getOEMPartApplicability(catalogCode, vehicleId, oemNumber, ssd) } catch (err) { console.error('Ошибка поиска детали по OEM номеру:', err) return null } }, laximoFulltextSearch: async (_: unknown, { catalogCode, vehicleId, searchQuery, ssd }: { catalogCode: string; vehicleId: string; searchQuery: string; ssd: string }, context: Context) => { try { console.log('🔍 GraphQL Resolver - Поиск деталей по названию:', { catalogCode, vehicleId, searchQuery, ssd: ssd ? `${ssd.substring(0, 30)}...` : 'отсутствует' }) // Сначала проверим поддержку полнотекстового поиска каталогом const catalogInfo = await laximoService.getCatalogInfo(catalogCode) if (catalogInfo) { const hasFulltextSearch = catalogInfo.features.some(f => f.name === 'fulltextsearch') console.log(`📋 Каталог ${catalogCode} поддерживает полнотекстовый поиск:`, hasFulltextSearch) if (!hasFulltextSearch) { console.log('⚠️ Каталог не поддерживает полнотекстовый поиск') // Сохраняем в историю поиска даже при отсутствии результатов await saveSearchHistory( context, searchQuery, 'TEXT', undefined, undefined, undefined, 0 ) return { searchQuery: searchQuery, details: [] } } } else { console.log('⚠️ Не удалось получить информацию о каталоге') } const result = await laximoService.searchVehicleDetails(catalogCode, vehicleId, searchQuery, ssd) console.log('📋 Результат от LaximoService:', result ? `найдено ${result.details.length} деталей` : 'результат null') // Сохраняем в историю поиска if (result) { await saveSearchHistory( context, searchQuery, 'TEXT', undefined, undefined, undefined, result.details.length ) } // Мапим данные для GraphQL схемы, добавляя отсутствующие поля if (result) { return { ...result, details: result.details.map(detail => ({ detailid: null, // Полнотекстовый поиск не возвращает detailid oem: detail.oem, formattedoem: detail.oem, // Используем oem как formattedoem name: detail.name, brand: detail.brand || null, description: detail.description || null, codeonimage: null, code: null, note: null, filter: null, parttype: null, price: null, availability: null, attributes: [] })) } } return result } catch (err) { console.error('❌ Ошибка в GraphQL resolver поиска деталей по названию:', err) return null } }, laximoDocFindOEM: async (_: unknown, { oemNumber, brand, replacementTypes }: { oemNumber: string; brand?: string; replacementTypes?: string }, context: Context) => { try { console.log('🔍 GraphQL Resolver - Doc FindOEM поиск по артикулу:', { oemNumber, brand, replacementTypes }) const result = await laximoDocService.findOEM(oemNumber, brand, replacementTypes) console.log('📋 Результат от LaximoDocService:', result ? `найдено ${result.details.length} деталей` : 'результат null') // Сохраняем в историю поиска if (result) { await saveSearchHistory( context, oemNumber, 'OEM', brand, oemNumber, undefined, result.details.length ) } return result } catch (err) { console.error('❌ Ошибка в GraphQL resolver Doc FindOEM:', err) return null } }, // Резолверы для работы с деталями узлов laximoUnitInfo: async (_: unknown, { catalogCode, vehicleId, unitId, ssd }: { catalogCode: string; vehicleId: string; unitId: string; ssd: string }) => { try { console.log('🔍 GraphQL Resolver - получение информации об узле:', { catalogCode, vehicleId, unitId }) const result = await laximoUnitService.getUnitInfo(catalogCode, vehicleId, unitId, ssd) console.log('📋 Результат от LaximoUnitService:', result ? `найден узел ${result.name}` : 'узел не найден') return result } catch (err) { console.error('❌ Ошибка в GraphQL resolver UnitInfo:', err) return null } }, laximoUnitDetails: async (_: unknown, { catalogCode, vehicleId, unitId, ssd }: { catalogCode: string; vehicleId: string; unitId: string; ssd: string }) => { try { console.log('🔍 GraphQL Resolver - получение деталей узла:', { catalogCode, vehicleId, unitId }) const result = await laximoUnitService.getUnitDetails(catalogCode, vehicleId, unitId, ssd) console.log('📋 Результат от LaximoUnitService:', result ? `найдено ${result.length} деталей` : 'детали не найдены') return result || [] } catch (err) { console.error('❌ Ошибка в GraphQL resolver UnitDetails:', err) return [] } }, laximoUnitImageMap: async (_: unknown, { catalogCode, vehicleId, unitId, ssd }: { catalogCode: string; vehicleId: string; unitId: string; ssd: string }) => { try { console.log('🔍 GraphQL Resolver - получение карты изображений узла:', { catalogCode, vehicleId, unitId }) const result = await laximoUnitService.getUnitImageMap(catalogCode, vehicleId, unitId, ssd) console.log('📋 Результат от LaximoUnitService:', result ? `найдена карта с ${result.coordinates.length} координатами` : 'карта не найдена') return result } catch (err) { console.error('❌ Ошибка в GraphQL resolver UnitImageMap:', err) return null } }, // Поиск товаров и предложений searchProductOffers: async (_: unknown, { articleNumber, brand }: { articleNumber: string; brand: string; }, context: Context) => { try { // Проверяем входные параметры if (!articleNumber || !brand || articleNumber.trim() === '' || brand.trim() === '') { console.log('❌ GraphQL Resolver - некорректные параметры:', { articleNumber, brand }) return { articleNumber: articleNumber || '', brand: brand || '', name: 'По запросу', internalOffers: [], externalOffers: [], analogs: [], hasInternalStock: false, totalOffers: 0 } } // Очищаем параметры const cleanArticleNumber = articleNumber.trim() const cleanBrand = brand.trim() console.log('🔍 GraphQL Resolver - поиск предложений для товара:', { articleNumber: cleanArticleNumber, brand: cleanBrand }) // 1. Поиск в нашей базе данных const internalProducts = await prisma.product.findMany({ where: { article: { equals: cleanArticleNumber, mode: 'insensitive' } }, include: { categories: true, images: { orderBy: { order: 'asc' } }, characteristics: { include: { characteristic: true } } } }) console.log(`📦 Найдено ${internalProducts.length} товаров в нашей базе`) // 2. Поиск в AutoEuro let externalOffers: any[] = [] try { console.log('🔍 GraphQL Resolver - начинаем поиск в AutoEuro:', { articleNumber: cleanArticleNumber, brand: cleanBrand }) const autoEuroResult = await autoEuroService.searchItems({ code: cleanArticleNumber, brand: cleanBrand, with_crosses: false, with_offers: true }) console.log('📊 GraphQL Resolver - результат AutoEuro:', { success: autoEuroResult.success, dataLength: autoEuroResult.data?.length || 0, error: autoEuroResult.error }) if (autoEuroResult.success && autoEuroResult.data) { console.log('✅ GraphQL Resolver - обрабатываем данные AutoEuro, количество:', autoEuroResult.data.length) externalOffers = autoEuroResult.data.map(offer => ({ offerKey: offer.offer_key, brand: offer.brand, code: offer.code, name: offer.name, price: parseFloat(offer.price.toString()), currency: offer.currency || 'RUB', deliveryTime: calculateDeliveryDays(offer.delivery_time || ''), deliveryTimeMax: calculateDeliveryDays(offer.delivery_time_max || ''), quantity: offer.amount || 0, warehouse: offer.warehouse_name || 'Внешний склад', warehouseName: offer.warehouse_name || null, rejects: offer.rejects || 0, supplier: 'AutoEuro', canPurchase: true })) console.log('🎯 GraphQL Resolver - создано внешних предложений:', externalOffers.length) } else { console.log('❌ GraphQL Resolver - AutoEuro не вернул данные:', autoEuroResult) } } catch (error) { console.error('❌ Ошибка поиска в AutoEuro:', error) } console.log(`🌐 Найдено ${externalOffers.length} предложений в AutoEuro`) console.log('📦 Первые 3 внешних предложения:', externalOffers.slice(0, 3)) // 3. Поиск в PartsIndex для получения дополнительных характеристик и изображений let partsIndexData: any = null try { console.log('🔍 GraphQL Resolver - прямой поиск в PartsIndex:', { articleNumber: cleanArticleNumber, brand: cleanBrand }) // Используем прямой поиск по артикулу и бренду partsIndexData = await partsIndexService.searchEntityByCode( cleanArticleNumber, cleanBrand ) if (partsIndexData) { console.log('✅ GraphQL Resolver - найден товар в PartsIndex:', { code: partsIndexData.code, brand: partsIndexData.brand?.name, images: partsIndexData.images?.length || 0, parameters: partsIndexData.parameters?.length || 0 }) } else { console.log('⚠️ GraphQL Resolver - товар не найден в PartsIndex') } } catch (error) { console.error('❌ Ошибка поиска в PartsIndex:', error) // Не бросаем ошибку, просто продолжаем без данных PartsIndex console.log('⚠️ Продолжаем без данных PartsIndex из-за ошибки API') } // 4. Поиск аналогов через AutoEuro (используем кроссы) const analogs: any[] = [] try { console.log('🔍 GraphQL Resolver - поиск аналогов через AutoEuro с кроссами') const analogsResult = await autoEuroService.searchItems({ code: cleanArticleNumber, brand: cleanBrand, with_crosses: true, // Включаем кроссы для получения аналогов with_offers: false }) if (analogsResult.success && analogsResult.data) { console.log('✅ GraphQL Resolver - найдены аналоги через AutoEuro:', analogsResult.data.length) // Фильтруем только кроссы и аналоги (не оригинальный товар) // Убираем дубликаты по комбинации brand + articleNumber const uniqueAnalogs = new Map() analogsResult.data .filter(item => item.cross !== null && item.cross !== undefined) .forEach(item => { const key = `${item.brand}-${item.code}` if (!uniqueAnalogs.has(key)) { const crossType = Number(item.cross) uniqueAnalogs.set(key, { brand: item.brand, articleNumber: item.code, name: item.name, type: crossType === 0 ? 'Кросс' : crossType === 1 ? 'Замена номера' : crossType === 2 ? 'Синоним бренда' : crossType === 3 ? 'Проверенный кросс' : crossType === 10 ? 'Комплект' : crossType === 11 ? 'Часть' : crossType === 12 ? 'Тюнинг' : 'Аналог' }) } }) // Берем первые 5 уникальных аналогов const analogsFromAutoEuro = Array.from(uniqueAnalogs.values()).slice(0, 5) analogs.push(...analogsFromAutoEuro) console.log('🎯 GraphQL Resolver - добавлено аналогов из AutoEuro:', analogsFromAutoEuro.length) } else { console.log('⚠️ GraphQL Resolver - AutoEuro не вернул аналоги') } } catch (error) { console.error('❌ Ошибка поиска аналогов через AutoEuro:', error) // Не бросаем ошибку, просто продолжаем без аналогов console.log('⚠️ Продолжаем без поиска аналогов из-за ошибки API') } console.log(`🔄 Найдено ${analogs.length} аналогов`) // 5. Формируем внутренние предложения const internalOffers = internalProducts.map(product => ({ id: product.id, productId: product.id, price: product.retailPrice || 0, quantity: product.stock || 0, warehouse: 'Основной склад', deliveryDays: 1, available: (product.stock || 0) > 0, rating: 4.8, supplier: 'Protek' })) // 6. Определяем название товара и собираем данные let productName = '' let productDescription = '' let productImages: any[] = [] let productCharacteristics: any[] = [] let partsIndexImages: any[] = [] let partsIndexCharacteristics: any[] = [] // Приоритет: внутренняя база -> PartsIndex -> AutoEuro if (internalProducts.length > 0) { const firstProduct = internalProducts[0] productName = firstProduct.name productDescription = firstProduct.description || '' productImages = firstProduct.images productCharacteristics = firstProduct.characteristics } // Добавляем данные из PartsIndex if (partsIndexData) { if (!productName) { productName = partsIndexData.name?.name || partsIndexData.originalName || `${cleanBrand} ${cleanArticleNumber}` } if (!productDescription && partsIndexData.description) { productDescription = partsIndexData.description } // Добавляем изображения из PartsIndex if (partsIndexData.images && Array.isArray(partsIndexData.images)) { partsIndexImages = partsIndexData.images.map((imageUrl: string, index: number) => ({ url: imageUrl, alt: `${productName} - изображение ${index + 1}`, order: index + 1, source: 'PartsIndex' })) } // Добавляем характеристики из PartsIndex if (partsIndexData.parameters && Array.isArray(partsIndexData.parameters)) { partsIndexCharacteristics = partsIndexData.parameters.flatMap((paramGroup: any) => paramGroup.params ? paramGroup.params.map((param: any) => ({ name: param.title || param.name || 'Характеристика', value: param.values && param.values.length > 0 ? param.values.map((v: any) => v.value).join(', ') : 'Не указано', source: 'PartsIndex' })) : [] ) } } // Если нет названия, используем данные из AutoEuro if (!productName && externalOffers.length > 0) { productName = externalOffers[0].name } // Если все еще нет названия, формируем из бренда и артикула if (!productName) { productName = `${cleanBrand} ${cleanArticleNumber}` } const result = { articleNumber: cleanArticleNumber, brand: cleanBrand, name: productName, description: productDescription, images: productImages, characteristics: productCharacteristics, partsIndexImages, partsIndexCharacteristics, internalOffers, externalOffers, analogs, hasInternalStock: internalOffers.some(offer => offer.available), totalOffers: internalOffers.length + externalOffers.length } console.log('✅ Результат поиска предложений:', { articleNumber: cleanArticleNumber, brand: cleanBrand, internalOffers: result.internalOffers.length, externalOffers: result.externalOffers.length, analogs: result.analogs.length, hasInternalStock: result.hasInternalStock }) console.log('🔍 Детали результата:') console.log('- Внутренние предложения:', result.internalOffers) console.log('- Внешние предложения:', result.externalOffers.slice(0, 3)) console.log('- Аналоги:', result.analogs.length) // Сохраняем в историю поиска await saveSearchHistory( context, `${cleanBrand} ${cleanArticleNumber}`, 'ARTICLE', cleanBrand, cleanArticleNumber, undefined, result.totalOffers ) return result } catch (error) { console.error('❌ Ошибка в GraphQL resolver searchProductOffers:', error) throw new Error('Не удалось найти предложения для товара') } }, getAnalogOffers: async (_: unknown, { analogs }: { analogs: { articleNumber: string; brand: string; name?: string; type?: string }[] }) => { try { console.log('🔍 GraphQL Resolver - поиск предложений для аналогов:', { count: analogs.length }) const analogPromises = analogs.map(async (analog) => { const { articleNumber, brand } = analog // Поиск в нашей базе const analogInternalProducts = await prisma.product.findMany({ where: { article: { equals: articleNumber, mode: 'insensitive' } }, }) // Формируем внутренние предложения const internalOffers = analogInternalProducts.map(product => ({ id: product.id, productId: product.id, price: product.retailPrice || 0, quantity: product.stock || 0, warehouse: 'Основной склад', deliveryDays: 1, available: (product.stock || 0) > 0, rating: 4.8, supplier: 'Protek' })) // Поиск в AutoEuro только для аналогов без внутренних предложений let analogExternalOffers: any[] = [] if (internalOffers.length === 0) { try { const analogAutoEuroResult = await autoEuroService.searchItems({ code: articleNumber, brand: brand, with_crosses: false, with_offers: true, }) if (analogAutoEuroResult.success && analogAutoEuroResult.data) { analogExternalOffers = analogAutoEuroResult.data .map((offer) => ({ offerKey: offer.offer_key, brand: offer.brand, code: offer.code, name: offer.name, price: parseFloat(offer.price.toString()), currency: offer.currency || 'RUB', deliveryTime: calculateDeliveryDays(offer.delivery_time || ''), deliveryTimeMax: calculateDeliveryDays(offer.delivery_time_max || ''), quantity: offer.amount || 0, warehouse: offer.warehouse_name || 'Внешний склад', warehouseName: offer.warehouse_name || null, rejects: offer.rejects || 0, supplier: 'AutoEuro', canPurchase: true, })) } } catch (error) { console.error(`❌ Ошибка поиска аналога ${articleNumber} в AutoEuro:`, error) } } // Определяем название товара let name = analog.name || `${brand} ${articleNumber}` // Используем имя из аналога, если есть if (analogInternalProducts.length > 0) { name = analogInternalProducts[0].name } else if (analogExternalOffers.length > 0 && analogExternalOffers[0].name) { name = analogExternalOffers[0].name } return { articleNumber, brand, name, type: analog.type || 'Аналог', internalOffers, externalOffers: analogExternalOffers, } }) const analogResults = await Promise.all(analogPromises) console.log('✅ GraphQL Resolver - поиск аналогов завершен:', { processedAnalogs: analogResults.length, totalOffers: analogResults.reduce((sum, result) => sum + (result.internalOffers?.length || 0) + (result.externalOffers?.length || 0), 0), }) return analogResults } catch (error) { console.error('❌ GraphQL Resolver - ошибка поиска аналогов:', error) return [] } }, getBrandsByCode: async (_: unknown, { code }: { code: string }, context: Context) => { try { console.log('🔍 GraphQL Resolver - поиск брендов по коду:', { code }) if (!code || code.trim() === '') { console.log('❌ GraphQL Resolver - некорректный код:', { code }) return { success: false, error: 'Код артикула не может быть пустым', brands: [] } } const cleanCode = code.trim() console.log('🔍 GraphQL Resolver - начинаем поиск брендов в AutoEuro:', { code: cleanCode }) const autoEuroResult = await autoEuroService.getBrandsByCode(cleanCode) console.log('📊 GraphQL Resolver - результат поиска брендов AutoEuro:', { success: autoEuroResult.success, brandsCount: autoEuroResult.data?.length || 0, error: autoEuroResult.error }) if (autoEuroResult.success && autoEuroResult.data) { console.log('✅ GraphQL Resolver - найдены бренды:', autoEuroResult.data.length) return { success: true, brands: autoEuroResult.data, error: null } } else { console.log('❌ GraphQL Resolver - AutoEuro не вернул бренды:', autoEuroResult) return { success: false, error: autoEuroResult.error || 'Бренды не найдены', brands: [] } } } catch (error) { console.error('❌ GraphQL Resolver - ошибка поиска брендов:', error) return { success: false, error: error instanceof Error ? error.message : 'Неизвестная ошибка', brands: [] } } }, getCategoryProductsWithOffers: async (_: unknown, { categoryName, excludeArticle, excludeBrand, limit = 5 }: { categoryName: string; excludeArticle: string; excludeBrand: string; limit?: number }) => { // Функция для определения ключевых слов категории const getCategoryKeywords = (categoryName: string): string[] => { const name = categoryName.toLowerCase() // Словарь категорий и их ключевых слов const categoryMappings: { [key: string]: string[] } = { 'шины': ['шина', 'покрышка', 'резина', 'tire'], 'масла': ['масло', 'oil', 'жидкость'], 'фильтры': ['фильтр', 'filter'], 'тормоза': ['тормоз', 'brake', 'колодка', 'диск'], 'аккумуляторы': ['аккумулятор', 'battery', 'батарея'], 'свечи': ['свеча', 'spark', 'зажигание'], 'стартеры': ['стартер', 'starter'], 'генераторы': ['генератор', 'alternator'], 'амортизаторы': ['амортизатор', 'shock', 'стойка'] } // Ищем совпадения for (const [category, keywords] of Object.entries(categoryMappings)) { if (name.includes(category)) { return keywords } } // Если категория не найдена, используем само название return [name] } // Функция для извлечения бренда из названия товара const extractBrandFromName = (productName: string): string => { const name = productName.trim() const words = name.split(' ') // Обычно бренд - это первое слово if (words.length > 0) { return words[0] } return name } try { console.log('🔍 GraphQL Resolver - поиск товаров категории с предложениями:', { categoryName, excludeArticle, excludeBrand, limit }) // 1. Определяем ключевые слова для поиска товаров из категории const categoryKeywords = getCategoryKeywords(categoryName) console.log('🏷️ Ключевые слова категории:', categoryKeywords) // 2. Поиск товаров в нашей базе данных по ключевым словам const internalProducts = await prisma.product.findMany({ where: { AND: [ // Исключаем текущий товар { NOT: { AND: [ { article: { equals: excludeArticle, mode: 'insensitive' } }, { name: { contains: excludeBrand, mode: 'insensitive' } } ] } }, // Поиск по ключевым словам категории { OR: categoryKeywords.map(keyword => ({ OR: [ { name: { contains: keyword, mode: 'insensitive' } }, { description: { contains: keyword, mode: 'insensitive' } }, { categories: { some: { name: { contains: keyword, mode: 'insensitive' } } } } ] })) } ] }, include: { categories: true }, take: limit * 3 // Берем больше товаров для проверки наличия предложений }) console.log(`📦 Найдено ${internalProducts.length} товаров в категории из нашей базы`) // 3. Проверяем наличие предложений AutoEuro для каждого товара const productsWithOffers: any[] = [] for (const product of internalProducts) { if (productsWithOffers.length >= limit) break // Извлекаем бренд из названия товара (обычно первое слово) const productBrand = extractBrandFromName(product.name) if (!product.article || !productBrand) { console.log('⚠️ Пропускаем товар без артикула или бренда:', product.name) continue } try { // Проверяем наличие предложений в AutoEuro const autoEuroResult = await autoEuroService.searchItems({ code: product.article, brand: productBrand, with_crosses: false, with_offers: true }) if (autoEuroResult.success && autoEuroResult.data && autoEuroResult.data.length > 0) { // Находим минимальную цену const minPrice = Math.min(...autoEuroResult.data.map(offer => parseFloat(offer.price.toString()))) productsWithOffers.push({ articleNumber: product.article, brand: productBrand, name: product.name, artId: product.id, // Используем ID товара как artId minPrice, hasOffers: true }) console.log('✅ Товар с предложениями:', { article: product.article, brand: productBrand, name: product.name, minPrice, offersCount: autoEuroResult.data.length }) } } catch (error) { console.error(`❌ Ошибка проверки предложений для ${product.article}:`, error) continue } } console.log(`🎯 Итого найдено товаров с предложениями: ${productsWithOffers.length}`) return productsWithOffers.slice(0, limit) } catch (error) { console.error('❌ GraphQL Resolver - ошибка поиска товаров категории:', error) return [] } }, // PartsAPI категории partsAPICategories: async (_: unknown, { carId, carType = 'PC' }: { carId: number; carType?: 'PC' | 'CV' | 'Motorcycle' }) => { try { console.log('🔍 GraphQL Resolver - PartsAPI категории:', { carId, carType }); const categories = await partsAPIService.getSearchTree(carId, carType); console.log('✅ GraphQL Resolver - получено категорий PartsAPI:', categories.length); return categories; } catch (error) { console.error('❌ Ошибка в GraphQL resolver partsAPICategories:', error) throw new Error('Не удалось получить категории PartsAPI') } }, partsAPITopLevelCategories: async (_: unknown, { carId, carType = 'PC' }: { carId: number; carType?: 'PC' | 'CV' | 'Motorcycle' }) => { try { console.log('🔍 GraphQL Resolver - PartsAPI категории верхнего уровня:', { carId, carType }); const tree = await partsAPIService.getSearchTree(carId, carType); const categories = partsAPIService.getTopLevelCategories(tree); console.log('✅ GraphQL Resolver - получено категорий верхнего уровня PartsAPI:', categories.length); return categories; } catch (error) { console.error('❌ Ошибка в GraphQL resolver partsAPITopLevelCategories:', error) throw new Error('Не удалось получить категории верхнего уровня PartsAPI') } }, partsAPIRootCategories: async (_: unknown, { carId, carType = 'PC' }: { carId: number; carType?: 'PC' | 'CV' | 'Motorcycle' }) => { try { console.log('🔍 GraphQL Resolver - PartsAPI корневые категории:', { carId, carType }); const tree = await partsAPIService.getSearchTree(carId, carType); const categories = partsAPIService.getRootCategories(tree); console.log('✅ GraphQL Resolver - получено корневых категорий PartsAPI:', categories.length); return categories; } catch (error) { console.error('❌ Ошибка в GraphQL resolver partsAPIRootCategories:', error) throw new Error('Не удалось получить корневые категории PartsAPI') } }, // PartsIndex категории автотоваров partsIndexCatalogs: async (_: unknown, { lang = 'ru' }: { lang?: 'ru' | 'en' }) => { try { console.log('🔍 GraphQL Resolver - PartsIndex каталоги:', { lang }); const catalogs = await partsIndexService.getCatalogs(lang); console.log('✅ GraphQL Resolver - получено каталогов PartsIndex:', catalogs.length); return catalogs.map(catalog => ({ ...catalog, groups: [] // Пустой массив групп, если нужны группы - используйте другой запрос })); } catch (error) { console.error('❌ Ошибка в GraphQL resolver partsIndexCatalogs:', error) throw new Error('Не удалось получить каталоги PartsIndex') } }, partsIndexCatalogGroups: async (_: unknown, { catalogId, lang = 'ru' }: { catalogId: string; lang?: 'ru' | 'en' }) => { try { console.log('🔍 GraphQL Resolver - PartsIndex группы каталога:', { catalogId, lang }); const groups = await partsIndexService.getCatalogGroups(catalogId, lang); console.log('✅ GraphQL Resolver - получено групп PartsIndex:', groups.length); return groups; } catch (error) { console.error('❌ Ошибка в GraphQL resolver partsIndexCatalogGroups:', error) throw new Error('Не удалось получить группы каталога PartsIndex') } }, partsIndexCategoriesWithGroups: async (_: unknown, { lang = 'ru' }: { lang?: 'ru' | 'en' }) => { try { console.log('🔍 GraphQL Resolver - PartsIndex категории с группами:', { lang }); const categoriesWithGroups = await partsIndexService.getCategoriesWithGroups(lang); console.log('✅ GraphQL Resolver - получено категорий с группами PartsIndex:', categoriesWithGroups.length); return categoriesWithGroups; } catch (error) { console.error('❌ Ошибка в GraphQL resolver partsIndexCategoriesWithGroups:', error) throw new Error('Не удалось получить категории с группами PartsIndex') } }, partsIndexCatalogEntities: async (_: unknown, { catalogId, groupId, lang = 'ru', limit = 25, page = 1, q, engineId, generationId, params }: { catalogId: string; groupId: string; lang?: 'ru' | 'en'; limit?: number; page?: number; q?: string; engineId?: string; generationId?: string; params?: string; }) => { try { console.log('🔍 GraphQL resolver partsIndexCatalogEntities вызван с параметрами:', { catalogId, groupId, lang, limit, page, q }) // Преобразуем строку params в объект если передан let parsedParams: Record | undefined; if (params) { try { parsedParams = JSON.parse(params); } catch (error) { console.warn('⚠️ Не удалось разобрать параметры фильтрации:', params); } } const entities = await partsIndexService.getCatalogEntities(catalogId, groupId, { lang, limit, page, q, engineId, generationId, params: parsedParams }) if (!entities) { console.warn('⚠️ Не удалось получить товары каталога') return { pagination: { limit, page: { prev: page > 1 ? page - 1 : 0, current: page, next: 0 } }, list: [], catalog: { id: catalogId, name: 'Неизвестная категория', image: '', groups: [] }, subgroup: null } } console.log('✅ Получены товары каталога:', entities.list.length) return entities } catch (error) { console.error('❌ Ошибка в GraphQL resolver partsIndexCatalogEntities:', error) throw new Error('Не удалось получить товары каталога') } }, partsIndexSearchByArticle: async (_: unknown, { articleNumber, brandName, lang = 'ru' }: { articleNumber: string; brandName: string; lang?: 'ru' | 'en' }) => { try { console.log('🔍 GraphQL resolver partsIndexSearchByArticle вызван с параметрами:', { articleNumber, brandName, lang }) // ВРЕМЕННО ОТКЛЮЧАЕМ ПОИСК В PARTSINDEX ДЛЯ КАРТОЧКИ ТОВАРА // чтобы избежать множественных запросов console.log('⚠️ Поиск в PartsIndex временно отключен для оптимизации') return null /* ЗАКОММЕНТИРОВАННЫЙ КОД ДЛЯ БУДУЩЕГО ИСПОЛЬЗОВАНИЯ const entity = await partsIndexService.searchEntityByArticle(articleNumber, brandName, lang) if (!entity) { console.log('❌ Товар не найден в Parts Index:', { articleNumber, brandName }) return null } console.log('✅ Товар найден в Parts Index:', entity.code, entity.brand.name) // Получаем детальную информацию о товаре // Поскольку у нас нет catalogId, попробуем найти товар через основные каталоги const catalogs = await partsIndexService.getCatalogs(lang) for (const catalog of catalogs) { try { const entityDetail = await partsIndexService.getEntityById(catalog.id, entity.id, lang) if (entityDetail) { console.log('✅ Получена детальная информация о товаре из каталога:', catalog.id) return entityDetail } } catch (error) { console.log('⚠️ Ошибка получения детальной информации из каталога:', catalog.id, error) continue } } // Если детальная информация не найдена, возвращаем базовую информацию console.log('⚠️ Детальная информация не найдена, возвращаем базовую') return { id: entity.id, catalog: { id: 'unknown', name: 'Неизвестный каталог', image: '', groups: [] }, subgroups: [], name: entity.name, originalName: entity.originalName, code: entity.code, barcodes: [], brand: entity.brand, description: '', parameters: entity.parameters.map(param => ({ id: param.id, name: param.title, params: [{ id: param.id, code: param.code, title: param.title, type: param.type, values: param.values }] })), images: entity.images, links: [] } */ } catch (error) { console.error('❌ Ошибка в GraphQL resolver partsIndexSearchByArticle:', error) return null } }, // Получить детальную информацию о товаре PartsIndex по ID partsIndexGetEntityById: async (_: unknown, { catalogId, entityId, lang = 'ru' }: { catalogId: string; entityId: string; lang?: 'ru' | 'en' }) => { try { console.log('🔍 GraphQL resolver partsIndexGetEntityById вызван с параметрами:', { catalogId, entityId, lang }) const entityDetail = await partsIndexService.getEntityById(catalogId, entityId, lang) if (!entityDetail) { console.log('❌ Деталь товара не найдена в Parts Index:', { catalogId, entityId }) return null } console.log('✅ Детальная информация товара получена из Parts Index:', entityDetail.code, entityDetail.brand.name) return entityDetail } catch (error) { console.error('❌ Ошибка в GraphQL resolver partsIndexGetEntityById:', error) return null } }, // PartsAPI артикулы partsAPIArticles: async (_: unknown, { strId, carId, carType = 'PC' }: { strId: number; carId: number; carType?: 'PC' | 'CV' | 'Motorcycle' }) => { try { console.log('🔍 GraphQL Resolver - PartsAPI артикулы:', { strId, carId, carType }); const articles = await partsAPIService.getArticles(strId, carId, carType); console.log('✅ GraphQL Resolver - получено артикулов PartsAPI:', articles.length); if (!articles || articles.length === 0) { console.log('⚠️ Артикулы для данной категории не найдены'); return []; } // Преобразуем названия полей для соответствия GraphQL схеме с проверкой на null/undefined const transformedArticles = articles.map(article => ({ supBrand: article.SUP_BRAND || '', supId: article.SUP_ID || 0, productGroup: article.PRODUCT_GROUP || '', ptId: article.PT_ID || 0, artSupBrand: article.ART_SUP_BRAND || '', artArticleNr: article.ART_ARTICLE_NR || '', artId: article.ART_ID || '' })); return transformedArticles; } catch (error) { console.error('❌ Ошибка в GraphQL resolver partsAPIArticles:', error) // Возвращаем пустой массив вместо выброса ошибки return []; } }, // PartsAPI изображения partsAPIMedia: async (_: unknown, { artId, lang = 16 }: { artId: string; lang?: number }) => { try { console.log('🖼️ GraphQL Resolver - PartsAPI изображения:', { artId, lang }); const media = await partsAPIService.getArticleMedia(artId, lang); console.log('✅ GraphQL Resolver - получено изображений PartsAPI:', media.length); if (!media || media.length === 0) { console.log('⚠️ Изображения для артикула не найдены'); return []; } // Преобразуем данные для GraphQL схемы const transformedMedia = media.map(item => ({ artMediaType: String(item.ART_MEDIA_TYPE), artMediaSource: item.ART_MEDIA_SOURCE, artMediaSupId: item.ART_MEDIA_SUP_ID, artMediaKind: item.ART_MEDIA_KIND || null, imageUrl: partsAPIService.getImageUrl(item.ART_MEDIA_SOURCE) })); return transformedMedia; } catch (error) { console.error('❌ GraphQL Resolver ошибка PartsAPI изображения:', error); return []; } }, // PartsAPI главное изображение partsAPIMainImage: async (_: unknown, { artId }: { artId: string }) => { try { console.log('🖼️ GraphQL Resolver - PartsAPI главное изображение:', { artId }); const imageUrl = await partsAPIService.getArticleMainImage(artId); if (imageUrl) { console.log('✅ GraphQL Resolver - получено главное изображение PartsAPI'); } else { console.log('⚠️ Главное изображение для артикула не найдено'); } return imageUrl; } catch (error) { console.error('❌ GraphQL Resolver ошибка PartsAPI главное изображение:', error); return null; } }, // Заказы и платежи orders: async (_: unknown, { clientId, status, search, limit = 50, offset = 0 }: { clientId?: string; status?: string; search?: string; limit?: number; offset?: number }, context: Context) => { try { const where: any = {} if (clientId) { where.clientId = clientId } if (status) { where.status = status } if (search) { where.OR = [ { orderNumber: { contains: search, mode: 'insensitive' } }, { clientName: { contains: search, mode: 'insensitive' } }, { clientEmail: { contains: search, mode: 'insensitive' } }, { clientPhone: { contains: search, mode: 'insensitive' } } ] } const [orders, total] = await Promise.all([ prisma.order.findMany({ where, include: { client: true, items: { include: { product: true } }, payments: true }, orderBy: { createdAt: 'desc' }, take: limit, skip: offset }), prisma.order.count({ where }) ]) return { orders, total, hasMore: offset + limit < total } } catch (error) { console.error('Ошибка получения заказов:', error) throw new Error('Не удалось получить заказы') } }, order: async (_: unknown, { id }: { id: string }) => { try { const order = await prisma.order.findUnique({ where: { id }, include: { client: true, items: { include: { product: true } }, payments: true } }) return order } catch (error) { console.error('Ошибка получения заказа:', error) throw new Error('Не удалось получить заказ') } }, orderByNumber: async (_: unknown, { orderNumber }: { orderNumber: string }) => { try { const order = await prisma.order.findUnique({ where: { orderNumber }, include: { client: true, items: { include: { product: true } }, payments: true } }) return order } catch (error) { console.error('Ошибка получения заказа по номеру:', error) throw new Error('Не удалось получить заказ') } }, payments: async (_: unknown, { orderId, status }: { orderId?: string; status?: string }) => { try { const where: any = {} if (orderId) { where.orderId = orderId } if (status) { where.status = status } const payments = await prisma.payment.findMany({ where, include: { order: { include: { client: true, items: true } } }, orderBy: { createdAt: 'desc' } }) return payments } catch (error) { console.error('Ошибка получения платежей:', error) throw new Error('Не удалось получить платежи') } }, payment: async (_: unknown, { id }: { id: string }) => { try { const payment = await prisma.payment.findUnique({ where: { id }, include: { order: { include: { client: true, items: true } } } }) return payment } catch (error) { console.error('Ошибка получения платежа:', error) throw new Error('Не удалось получить платеж') } }, // Резолверы для Яндекс доставки yandexDetectLocation: async (_: unknown, { location }: { location: string }) => { try { const response = await yandexDeliveryService.detectLocation(location) return response.variants.map(variant => ({ address: variant.address, geoId: variant.geo_id })) } catch (error) { console.error('Ошибка определения местоположения:', error) throw new Error('Не удалось определить местоположение') } }, yandexPickupPoints: async (_: unknown, { filters }: { filters?: any }) => { try { const request: any = {} if (filters) { if (filters.geoId) request.geo_id = filters.geoId if (filters.latitude && filters.longitude) { const radiusKm = filters.radiusKm || 10 const radiusDegrees = radiusKm / 111 request.latitude = { from: filters.latitude - radiusDegrees, to: filters.latitude + radiusDegrees } request.longitude = { from: filters.longitude - radiusDegrees, to: filters.longitude + radiusDegrees } } if (filters.isYandexBranded !== undefined) request.is_yandex_branded = filters.isYandexBranded if (filters.isPostOffice !== undefined) request.is_post_office = filters.isPostOffice if (filters.type) request.type = filters.type } const response = await yandexDeliveryService.getPickupPoints(request) return response.points.map((point: YandexPickupPoint) => ({ id: point.id, name: point.name, address: { fullAddress: point.address.full_address, locality: point.address.locality, street: point.address.street, house: point.address.house, building: point.address.building, apartment: point.address.apartment, postalCode: point.address.postal_code, comment: point.address.comment }, contact: { phone: point.contact.phone, email: point.contact.email, firstName: point.contact.first_name, lastName: point.contact.last_name }, position: { latitude: point.position.latitude, longitude: point.position.longitude }, schedule: { restrictions: point.schedule.restrictions.map(restriction => ({ days: restriction.days, timeFrom: { hours: restriction.time_from.hours, minutes: restriction.time_from.minutes }, timeTo: { hours: restriction.time_to.hours, minutes: restriction.time_to.minutes } })), timeZone: point.schedule.time_zone }, type: point.type, paymentMethods: point.payment_methods, instruction: point.instruction, isDarkStore: point.is_dark_store || false, isMarketPartner: point.is_market_partner || false, isPostOffice: point.is_post_office || false, isYandexBranded: point.is_yandex_branded || false, formattedSchedule: yandexDeliveryService.formatSchedule(point.schedule), typeLabel: yandexDeliveryService.getTypeLabel(point.type) })) } catch (error) { console.error('Ошибка получения ПВЗ:', error) throw new Error('Не удалось получить список ПВЗ') } }, yandexPickupPointsByCity: async (_: unknown, { cityName }: { cityName: string }) => { try { console.log('Запрос ПВЗ для города:', cityName) const points = await yandexDeliveryService.getPickupPointsByCity(cityName) console.log('Получено ПВЗ:', points.length) if (points.length > 0) { console.log('Первый ПВЗ:', JSON.stringify(points[0], null, 2)) } // Если ПВЗ не найдены, возвращаем пустой массив if (points.length === 0) { console.log(`ПВЗ в городе "${cityName}" не найдены`) return []; } return points.map(point => ({ id: point.id, name: point.name, address: { fullAddress: point.address.full_address, locality: point.address.locality, street: point.address.street, house: point.address.house, building: point.address.building, apartment: point.address.apartment, postalCode: point.address.postal_code, comment: point.address.comment }, contact: { phone: point.contact.phone, email: point.contact.email, firstName: point.contact.first_name, lastName: point.contact.last_name }, position: { latitude: point.position.latitude, longitude: point.position.longitude }, schedule: { restrictions: point.schedule.restrictions.map(restriction => ({ days: restriction.days, timeFrom: { hours: restriction.time_from.hours, minutes: restriction.time_from.minutes }, timeTo: { hours: restriction.time_to.hours, minutes: restriction.time_to.minutes } })), timeZone: point.schedule.time_zone }, type: point.type, paymentMethods: point.payment_methods, instruction: point.instruction, isDarkStore: point.is_dark_store || false, isMarketPartner: point.is_market_partner || false, isPostOffice: point.is_post_office || false, isYandexBranded: point.is_yandex_branded || false, formattedSchedule: yandexDeliveryService.formatSchedule(point.schedule), typeLabel: yandexDeliveryService.getTypeLabel(point.type) })) } catch (error) { console.error('Ошибка получения ПВЗ по городу:', error) throw new Error('Не удалось получить ПВЗ для указанного города') } }, yandexPickupPointsByCoordinates: async (_: unknown, { latitude, longitude, radiusKm }: { latitude: number; longitude: number; radiusKm?: number }) => { try { console.log('Запрос ПВЗ по координатам:', latitude, longitude, radiusKm) const points = await yandexDeliveryService.getPickupPointsByCoordinates(latitude, longitude, radiusKm) console.log('Получено ПВЗ по координатам:', points.length) // Если ПВЗ не найдены, возвращаем пустой массив if (points.length === 0) { console.log(`ПВЗ по координатам ${latitude}, ${longitude} не найдены`) return []; } return points.map(point => ({ id: point.id, name: point.name, address: { fullAddress: point.address.full_address, locality: point.address.locality, street: point.address.street, house: point.address.house, building: point.address.building, apartment: point.address.apartment, postalCode: point.address.postal_code, comment: point.address.comment }, contact: { phone: point.contact.phone, email: point.contact.email, firstName: point.contact.first_name, lastName: point.contact.last_name }, position: { latitude: point.position.latitude, longitude: point.position.longitude }, schedule: { restrictions: point.schedule.restrictions.map(restriction => ({ days: restriction.days, timeFrom: { hours: restriction.time_from.hours, minutes: restriction.time_from.minutes }, timeTo: { hours: restriction.time_to.hours, minutes: restriction.time_to.minutes } })), timeZone: point.schedule.time_zone }, type: point.type, paymentMethods: point.payment_methods, instruction: point.instruction, isDarkStore: point.is_dark_store || false, isMarketPartner: point.is_market_partner || false, isPostOffice: point.is_post_office || false, isYandexBranded: point.is_yandex_branded || false, formattedSchedule: yandexDeliveryService.formatSchedule(point.schedule), typeLabel: yandexDeliveryService.getTypeLabel(point.type) })) } catch (error) { console.error('Ошибка получения ПВЗ по координатам:', error) throw new Error('Не удалось получить ПВЗ по координатам') } }, // Автокомплит адресов addressSuggestions: async (_: unknown, { query }: { query: string }) => { try { console.log('Запрос автокомплита адресов:', query) const suggestions = await getAddressSuggestions(query) console.log('Получено предложений:', suggestions.length) return suggestions } catch (error) { console.error('Ошибка получения предложений адресов:', error) return [] } } }, ClientProfile: { _count: (parent: { _count?: { clients: number } }) => { return parent._count || { clients: 0 } } }, ClientLegalEntity: { bankDetails: async (parent: { id: string; bankDetails?: unknown[] }) => { // Если bankDetails не загружены, загружаем их из базы данных if (!parent.bankDetails) { const bankDetails = await prisma.clientBankDetails.findMany({ where: { legalEntityId: parent.id } }) return bankDetails || [] } return parent.bankDetails || [] } }, Mutation: { createUser: async (_: unknown, { input }: { input: CreateUserInput }, context: Context) => { try { const { firstName, lastName, email, password, avatar, role } = input // Проверяем, существует ли пользователь с таким email const existingUser = await prisma.user.findUnique({ where: { email } }) if (existingUser) { throw new Error('Пользователь с таким email уже существует') } // Хешируем пароль const hashedPassword = await hashPassword(password) // Создаем пользователя const user = await prisma.user.create({ data: { firstName, lastName, email, password: hashedPassword, avatar, role: role || 'USER' } }) // Логируем действие if (context.userId && context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.USER_CREATE, details: `${firstName} ${lastName} (${email})`, ipAddress, userAgent }) } return user } catch (error) { console.error('Ошибка создания пользователя:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать пользователя') } }, login: async (_: unknown, { input }: { input: LoginInput }, context: Context) => { try { const { email, password } = input // Находим пользователя по email const user = await prisma.user.findUnique({ where: { email } }) if (!user) { throw new Error('Неверный email или пароль') } // Проверяем пароль const isValidPassword = await comparePasswords(password, user.password) if (!isValidPassword) { throw new Error('Неверный email или пароль') } // Создаем JWT токен const token = createToken({ userId: user.id, email: user.email, role: user.role }) // Логируем вход if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: user.id, action: AuditAction.USER_LOGIN, ipAddress, userAgent }) } return { token, user } } catch (error) { console.error('Ошибка входа:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось войти в систему') } }, logout: async (_: unknown, __: unknown, context: Context) => { // Логируем выход if (context.userId && context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.USER_LOGOUT, ipAddress, userAgent }) } return true }, updateProfile: async (_: unknown, { input }: { input: UpdateProfileInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } // Проверяем, если изменяется email, что он уникален if (input.email) { const existingUser = await prisma.user.findFirst({ where: { email: input.email, id: { not: context.userId } } }) if (existingUser) { throw new Error('Пользователь с таким email уже существует') } } const updatedUser = await prisma.user.update({ where: { id: context.userId }, data: { ...(input.firstName && { firstName: input.firstName }), ...(input.lastName && { lastName: input.lastName }), ...(input.email && { email: input.email }), ...(input.avatar && { avatar: input.avatar }), } }) // Логируем действие if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.PROFILE_UPDATE, ipAddress, userAgent }) } return updatedUser } catch (error) { console.error('Ошибка обновления профиля:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить профиль') } }, changePassword: async (_: unknown, { input }: { input: ChangePasswordInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const user = await prisma.user.findUnique({ where: { id: context.userId } }) if (!user) { throw new Error('Пользователь не найден') } // Проверяем текущий пароль const isValidPassword = await comparePasswords(input.currentPassword, user.password) if (!isValidPassword) { throw new Error('Неверный текущий пароль') } // Хешируем новый пароль const hashedNewPassword = await hashPassword(input.newPassword) // Обновляем пароль await prisma.user.update({ where: { id: context.userId }, data: { password: hashedNewPassword } }) // Логируем действие if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.PASSWORD_CHANGE, details: 'Собственный пароль', ipAddress, userAgent }) } return true } catch (error) { console.error('Ошибка смены пароля:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось сменить пароль') } }, uploadAvatar: async (_: unknown, { file }: { file: string }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const updatedUser = await prisma.user.update({ where: { id: context.userId }, data: { avatar: file } }) // Логируем действие if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.AVATAR_UPLOAD, ipAddress, userAgent }) } return updatedUser } catch (error) { console.error('Ошибка загрузки аватара:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось загрузить аватар') } }, // Админские мутации для управления пользователями updateUser: async (_: unknown, { id, input }: { id: string; input: UpdateUserInput }, context: Context) => { try { if (!context.userId || context.userRole !== 'ADMIN') { throw new Error('Недостаточно прав для выполнения операции') } // Получаем данные пользователя до изменения const oldUser = await prisma.user.findUnique({ where: { id } }) if (!oldUser) { throw new Error('Пользователь не найден') } // Проверяем, если изменяется email, что он уникален if (input.email) { const existingUser = await prisma.user.findFirst({ where: { email: input.email, id: { not: id } } }) if (existingUser) { throw new Error('Пользователь с таким email уже существует') } } const updatedUser = await prisma.user.update({ where: { id }, data: { ...(input.firstName && { firstName: input.firstName }), ...(input.lastName && { lastName: input.lastName }), ...(input.email && { email: input.email }), ...(input.avatar !== undefined && { avatar: input.avatar }), ...(input.role && { role: input.role }), } }) // Логируем действие if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.USER_UPDATE, details: `${oldUser.firstName} ${oldUser.lastName} (${oldUser.email})`, ipAddress, userAgent }) } return updatedUser } catch (error) { console.error('Ошибка обновления пользователя:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить пользователя') } }, deleteUser: async (_: unknown, { id }: { id: string }, context: Context) => { try { if (!context.userId || context.userRole !== 'ADMIN') { throw new Error('Недостаточно прав для выполнения операции') } // Нельзя удалить самого себя if (context.userId === id) { throw new Error('Нельзя удалить собственный аккаунт') } // Получаем данные пользователя перед удалением const userToDelete = await prisma.user.findUnique({ where: { id } }) if (!userToDelete) { throw new Error('Пользователь не найден') } // Проверяем и обрабатываем связанные записи // 1. Обнуляем userId в client_balance_history (вместо удаления истории) await prisma.clientBalanceHistory.updateMany({ where: { userId: id }, data: { userId: null } }) // 2. Обнуляем managerId в таблице clients (переназначаем менеджера) await prisma.client.updateMany({ where: { managerId: id }, data: { managerId: null } }) // 3. Удаляем записи в audit_log связанные с пользователем await prisma.auditLog.deleteMany({ where: { userId: id } }) // Теперь можно безопасно удалить пользователя await prisma.user.delete({ where: { id } }) // Логируем действие if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.USER_DELETE, details: `${userToDelete.firstName} ${userToDelete.lastName} (${userToDelete.email})`, ipAddress, userAgent }) } return true } catch (error) { console.error('Ошибка удаления пользователя:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить пользователя') } }, adminChangePassword: async (_: unknown, { input }: { input: AdminChangePasswordInput }, context: Context) => { try { if (!context.userId || context.userRole !== 'ADMIN') { throw new Error('Недостаточно прав для выполнения операции') } // Получаем данные пользователя const targetUser = await prisma.user.findUnique({ where: { id: input.userId } }) if (!targetUser) { throw new Error('Пользователь не найден') } // Хешируем новый пароль const hashedNewPassword = await hashPassword(input.newPassword) // Обновляем пароль пользователя await prisma.user.update({ where: { id: input.userId }, data: { password: hashedNewPassword } }) // Логируем действие if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.PASSWORD_CHANGE, details: `Пароль пользователя ${targetUser.firstName} ${targetUser.lastName} (${targetUser.email})`, ipAddress, userAgent }) } return true } catch (error) { console.error('Ошибка смены пароля пользователя:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось сменить пароль пользователя') } }, // Категории createCategory: async (_: unknown, { input }: { input: CategoryInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const slug = input.slug || createSlug(input.name) // Проверяем уникальность slug const existingCategory = await prisma.category.findUnique({ where: { slug } }) if (existingCategory) { throw new Error('Категория с таким адресом уже существует') } const category = await prisma.category.create({ data: { ...input, slug }, include: { parent: true, children: true } }) // Логируем действие if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.CATEGORY_CREATE, details: `Категория "${input.name}"`, ipAddress, userAgent }) } return category } catch (error) { console.error('Ошибка создания категории:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать категорию') } }, updateCategory: async (_: unknown, { id, input }: { id: string; input: CategoryInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const updateData: Record = { ...input } if (input.name && !input.slug) { updateData.slug = createSlug(input.name) } const category = await prisma.category.update({ where: { id }, data: updateData, include: { parent: true, children: true } }) // Логируем действие if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.CATEGORY_UPDATE, details: `Категория "${category.name}"`, ipAddress, userAgent }) } return category } catch (error) { console.error('Ошибка обновления категории:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить категорию') } }, deleteCategory: async (_: unknown, { id }: { id: string }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const category = await prisma.category.findUnique({ where: { id }, include: { children: true, products: true } }) if (!category) { throw new Error('Категория не найдена') } if (category.children.length > 0) { throw new Error('Нельзя удалить категорию с подкатегориями') } if (category.products.length > 0) { throw new Error('Нельзя удалить категорию с товарами') } await prisma.category.delete({ where: { id } }) // Логируем действие if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.CATEGORY_DELETE, details: `Категория "${category.name}"`, ipAddress, userAgent }) } return true } catch (error) { console.error('Ошибка удаления категории:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить категорию') } }, // Товары createProduct: async (_: unknown, { input, images = [], characteristics = [], options = [] }: { input: ProductInput; images?: ProductImageInput[]; characteristics?: CharacteristicInput[]; options?: ProductOptionInput[] }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const slug = input.slug || createSlug(input.name) // Проверяем уникальность slug const existingProduct = await prisma.product.findUnique({ where: { slug } }) if (existingProduct) { throw new Error('Товар с таким адресом уже существует') } // Проверяем уникальность артикула if (input.article) { const existingByArticle = await prisma.product.findUnique({ where: { article: input.article } }) if (existingByArticle) { throw new Error('Товар с таким артикулом уже существует') } } const { categoryIds, ...productData } = input // Создаем товар const product = await prisma.product.create({ data: { ...productData, slug, categories: categoryIds ? { connect: categoryIds.map(id => ({ id })) } : undefined, images: { create: images.map((img, index) => ({ ...img, order: img.order ?? index })) } } }) // Добавляем характеристики for (const char of characteristics) { let characteristic = await prisma.characteristic.findUnique({ where: { name: char.name } }) if (!characteristic) { characteristic = await prisma.characteristic.create({ data: { name: char.name } }) } await prisma.productCharacteristic.create({ data: { productId: product.id, characteristicId: characteristic.id, value: char.value } }) } // Добавляем опции for (const optionInput of options) { // Создаём или находим опцию let option = await prisma.option.findUnique({ where: { name: optionInput.name } }) if (!option) { option = await prisma.option.create({ data: { name: optionInput.name, type: optionInput.type } }) } // Создаём значения опции и связываем с товаром for (const valueInput of optionInput.values) { // Создаём или находим значение опции let optionValue = await prisma.optionValue.findFirst({ where: { optionId: option.id, value: valueInput.value } }) if (!optionValue) { optionValue = await prisma.optionValue.create({ data: { optionId: option.id, value: valueInput.value, price: valueInput.price || 0 } }) } // Связываем товар с опцией и значением await prisma.productOption.create({ data: { productId: product.id, optionId: option.id, optionValueId: optionValue.id } }) } } // Получаем созданный товар со всеми связанными данными const createdProduct = await prisma.product.findUnique({ where: { id: product.id }, include: { categories: true, images: { orderBy: { order: 'asc' } }, options: { include: { option: { include: { values: true } }, optionValue: true } }, characteristics: { include: { characteristic: true } }, relatedProducts: { include: { images: { orderBy: { order: 'asc' } } } }, accessoryProducts: { include: { images: { orderBy: { order: 'asc' } } } } } }) // Создаем запись в истории товара if (context.userId) { await prisma.productHistory.create({ data: { productId: product.id, action: 'CREATE', changes: JSON.stringify({ name: input.name, article: input.article, description: input.description, wholesalePrice: input.wholesalePrice, retailPrice: input.retailPrice, stock: input.stock, isVisible: input.isVisible, categories: categoryIds, images: images.length, characteristics: characteristics.length, options: options.length }), userId: context.userId } }) } // Логируем действие if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.PRODUCT_CREATE, details: `Товар "${input.name}"`, ipAddress, userAgent }) } return createdProduct } catch (error) { console.error('Ошибка создания товара:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать товар') } }, updateProductVisibility: async (_: unknown, { id, isVisible }: { id: string; isVisible: boolean }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const product = await prisma.product.update({ where: { id }, data: { isVisible }, include: { categories: true, images: { orderBy: { order: 'asc' } }, options: { include: { option: { include: { values: true } }, optionValue: true } }, characteristics: { include: { characteristic: true } }, relatedProducts: { include: { images: { orderBy: { order: 'asc' } } } }, accessoryProducts: { include: { images: { orderBy: { order: 'asc' } } } } } }) // Логируем действие if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.PRODUCT_UPDATE, details: `Изменена видимость товара "${product.name}" на ${isVisible ? 'видимый' : 'скрытый'}`, ipAddress, userAgent }) } return product } catch (error) { console.error('Ошибка изменения видимости товара:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось изменить видимость товара') } }, // Массовые операции с товарами deleteProducts: async (_: unknown, { ids }: { ids: string[] }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } if (!ids || ids.length === 0) { throw new Error('Не указаны товары для удаления') } // Получаем информацию о товарах для логирования const products = await prisma.product.findMany({ where: { id: { in: ids } }, select: { id: true, name: true } }) // Удаляем товары const result = await prisma.product.deleteMany({ where: { id: { in: ids } } }) // Логируем действие if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.PRODUCT_DELETE, // eslint-disable-next-line @typescript-eslint/no-explicit-any details: `Массовое удаление товаров: ${products.map((p: any) => p.name).join(', ')} (${result.count} шт.)`, ipAddress, userAgent }) } return { count: result.count } } catch (error) { console.error('Ошибка массового удаления товаров:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить товары') } }, updateProductsVisibility: async (_: unknown, { ids, isVisible }: { ids: string[]; isVisible: boolean }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } if (!ids || ids.length === 0) { throw new Error('Не указаны товары для изменения видимости') } // Получаем информацию о товарах для логирования const products = await prisma.product.findMany({ where: { id: { in: ids } }, select: { id: true, name: true } }) // Обновляем видимость товаров const result = await prisma.product.updateMany({ where: { id: { in: ids } }, data: { isVisible } }) // Логируем действие if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.PRODUCT_UPDATE, // eslint-disable-next-line @typescript-eslint/no-explicit-any details: `Массовое изменение видимости товаров на ${isVisible ? 'видимые' : 'скрытые'}: ${products.map((p: any) => p.name).join(', ')} (${result.count} шт.)`, ipAddress, userAgent }) } return { count: result.count } } catch (error) { console.error('Ошибка массового изменения видимости товаров:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось изменить видимость товаров') } }, exportProducts: async (_: unknown, { categoryId, search }: { categoryId?: string; search?: string; format?: string }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } // Получаем товары с теми же фильтрами, что и в списке const where: Record = {} if (categoryId) { where.categories = { some: { id: categoryId } } } if (search) { where.OR = [ { name: { contains: search, mode: 'insensitive' } }, { article: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } } ] } const products = await prisma.product.findMany({ where, include: { categories: true, images: { orderBy: { order: 'asc' } }, characteristics: { include: { characteristic: true } }, options: { include: { option: true, optionValue: true } } }, orderBy: { name: 'asc' } }) // Создаем CSV данные const csvData = products.map(product => ({ id: product.id, name: product.name, article: product.article || '', description: product.description || '', wholesalePrice: product.wholesalePrice || 0, retailPrice: product.retailPrice || 0, stock: product.stock, isVisible: product.isVisible ? 'Да' : 'Нет', weight: product.weight || 0, dimensions: product.dimensions || '', unit: product.unit, categories: product.categories.map(cat => cat.name).join(', '), images: product.images.map(img => img.url).join(', '), characteristics: product.characteristics.map(char => `${char.characteristic.name}: ${char.value}` ).join('; '), options: product.options.map(opt => `${opt.option.name}: ${opt.optionValue.value} (+${opt.optionValue.price}₽)` ).join('; '), videoUrl: product.videoUrl || '', createdAt: product.createdAt instanceof Date ? product.createdAt.toISOString() : product.createdAt, updatedAt: product.updatedAt instanceof Date ? product.updatedAt.toISOString() : product.updatedAt })) // Создаем CSV строку const createCsvWriter = csvWriter.createObjectCsvStringifier({ header: [ { id: 'id', title: 'ID' }, { id: 'name', title: 'Название' }, { id: 'article', title: 'Артикул' }, { id: 'description', title: 'Описание' }, { id: 'wholesalePrice', title: 'Цена опт' }, { id: 'retailPrice', title: 'Цена розница' }, { id: 'stock', title: 'Остаток' }, { id: 'isVisible', title: 'Видимый' }, { id: 'weight', title: 'Вес' }, { id: 'dimensions', title: 'Размеры' }, { id: 'unit', title: 'Единица' }, { id: 'categories', title: 'Категории' }, { id: 'images', title: 'Изображения' }, { id: 'characteristics', title: 'Характеристики' }, { id: 'options', title: 'Опции' }, { id: 'videoUrl', title: 'Видео' }, { id: 'createdAt', title: 'Создан' }, { id: 'updatedAt', title: 'Обновлен' } ] }) const csvString = createCsvWriter.getHeaderString() + createCsvWriter.stringifyRecords(csvData) const csvBuffer = Buffer.from(csvString, 'utf8') // Генерируем имя файла const timestamp = new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-') const filename = `products-export-${timestamp}.csv` const key = generateFileKey(filename, 'exports') // Загружаем в S3 const uploadResult = await uploadBuffer(csvBuffer, key, 'text/csv') // Логируем действие if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.PRODUCT_UPDATE, // Можно добавить новый тип EXPORT details: `Экспорт товаров: ${products.length} шт. (${categoryId ? 'категория' : 'все'})`, ipAddress, userAgent }) } return { url: uploadResult.url, filename, count: products.length } } catch (error) { console.error('Ошибка экспорта товаров:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось экспортировать товары') } }, importProducts: async (_: unknown, { input }: { input: { file: string; categoryId?: string; replaceExisting?: boolean } }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } // Декодируем base64 файл console.log('Начало импорта товаров, пользователь:', context.userId) const fileData = Buffer.from(input.file, 'base64') console.log('Размер файла:', fileData.length, 'байт') let headers: string[] = [] let dataRows: string[][] = [] // Определяем тип файла по содержимому и размеру const hasExcelSignature = (fileData[0] === 0x50 && fileData[1] === 0x4B) || // PK (Excel/ZIP signature) (fileData[0] === 0xD0 && fileData[1] === 0xCF) // OLE signature (старые Excel файлы) // Дополнительно проверяем размер файла (Excel файлы обычно больше 1KB) const isExcel = hasExcelSignature && fileData.length > 1024 if (isExcel) { try { // Парсим Excel файл console.log('Парсим Excel файл, размер:', fileData.length, 'байт') const workbook = XLSX.read(fileData, { type: 'buffer' }) if (!workbook.SheetNames || workbook.SheetNames.length === 0) { throw new Error('Excel файл не содержит листов с данными') } const sheetName = workbook.SheetNames[0] const worksheet = workbook.Sheets[sheetName] const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as string[][] console.log('Обработано строк из Excel:', jsonData.length) if (jsonData.length < 2) { throw new Error('Файл должен содержать заголовки и хотя бы одну строку данных') } headers = jsonData[0].map(h => String(h || '').trim()) dataRows = jsonData.slice(1).filter(row => row.some(cell => String(cell || '').trim())) console.log('Заголовки:', headers) console.log('Строк данных:', dataRows.length) } catch (excelError) { console.error('Ошибка парсинга Excel файла:', excelError) throw new Error('Не удалось прочитать Excel файл. Убедитесь, что файл не поврежден.') } } else { try { // Парсим как CSV файл console.log('Парсим как CSV файл, размер:', fileData.length, 'байт') const fileContent = fileData.toString('utf-8') const lines = fileContent.split('\n').filter(line => line.trim()) console.log('Строк в CSV:', lines.length) if (lines.length < 2) { throw new Error('Файл должен содержать заголовки и хотя бы одну строку данных') } headers = lines[0].split(',').map(h => h.replace(/"/g, '').trim()) dataRows = lines.slice(1).map(line => line.split(',').map(v => v.replace(/"/g, '').trim()) ) console.log('Заголовки CSV:', headers) console.log('Строк данных CSV:', dataRows.length) } catch (csvError) { console.error('Ошибка парсинга CSV файла:', csvError) throw new Error('Не удалось прочитать файл. Поддерживаются только форматы .xlsx и .csv') } } const result = { success: 0, errors: [] as string[], total: dataRows.length, warnings: [] as string[] } // Обрабатываем каждую строку for (let i = 0; i < dataRows.length; i++) { const lineNumber = i + 2 try { const values = dataRows[i].map(v => String(v || '').trim()) // Если строка содержит меньше колонок, дополняем пустыми значениями // Если больше - обрезаем до нужного количества while (values.length < headers.length) { values.push('') } if (values.length > headers.length) { values.splice(headers.length) } // Создаем объект из заголовков и значений const rowData: Record = {} headers.forEach((header, index) => { rowData[header] = values[index] }) // Валидация обязательных полей const name = rowData['Название'] || rowData['Наименование'] || rowData['name'] || '' if (!name) { result.errors.push(`Строка ${lineNumber}: отсутствует название товара`) continue } // Проверяем существование товара по артикулу const article = rowData['Артикул'] || rowData['article'] || '' let existingProduct: any = null if (article) { existingProduct = await prisma.product.findFirst({ where: { article } }) } // Если товар существует и не включен режим замещения if (existingProduct && !input.replaceExisting) { result.warnings.push(`Строка ${lineNumber}: товар с артикулом "${article}" уже существует`) continue } // Подготовка данных для создания/обновления товара const manufacturer = rowData['Производитель'] || rowData['manufacturer'] || '' const description = rowData['Описание'] || rowData['description'] || (manufacturer ? `Производитель: ${manufacturer}` : undefined) const productData = { name: name, article: article || undefined, description: description, wholesalePrice: parseFloat(rowData['Цена опт'] || rowData['wholesalePrice'] || rowData['Цена АвтоЕвро ООО НДС'] || '0') || undefined, retailPrice: parseFloat(rowData['Цена розница'] || rowData['retailPrice'] || rowData['Цена АвтоЕвро ООО НДС'] || '0') || undefined, stock: parseInt(rowData['Остаток'] || rowData['Доступно'] || rowData['stock'] || '0') || 0, unit: rowData['Единица'] || rowData['unit'] || 'шт', weight: parseFloat(rowData['Вес'] || rowData['weight'] || '0') || undefined, dimensions: rowData['Размеры'] || rowData['dimensions'] || undefined, isVisible: true } // Генерируем slug const slug = createSlug(productData.name) if (existingProduct && input.replaceExisting) { // Обновляем существующий товар await prisma.product.update({ where: { id: existingProduct.id }, data: { ...productData, slug, updatedAt: new Date() } }) } else { // Создаем новый товар const createData: any = { ...productData, slug } // Добавляем категорию если указана if (input.categoryId) { createData.categories = { connect: [{ id: input.categoryId }] } } await prisma.product.create({ data: createData }) } result.success++ } catch (error) { console.error(`Ошибка обработки строки ${lineNumber}:`, error) result.errors.push(`Строка ${lineNumber}: ошибка создания товара`) } } // Логируем действие console.log('Результат импорта:', { total: result.total, success: result.success, errors: result.errors.length, warnings: result.warnings.length }) if (context.headers) { const { ipAddress, userAgent } = getClientInfo(context.headers) await createAuditLog({ userId: context.userId, action: AuditAction.PRODUCT_UPDATE, details: `Импорт товаров: ${result.success} успешно, ${result.errors.length} ошибок`, ipAddress, userAgent }) } return result } catch (error) { console.error('Ошибка импорта товаров:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось импортировать товары') } }, // Опции createOption: async (_: unknown, { input }: { input: OptionInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const option = await prisma.option.create({ data: { name: input.name, type: input.type, values: { create: input.values.map(value => ({ value: value.value, price: value.price || 0 })) } }, include: { values: true } }) return option } catch (error) { console.error('Ошибка создания опции:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать опцию') } }, updateOption: async (_: unknown, { id, input }: { id: string; input: OptionInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } // Удаляем старые значения await prisma.optionValue.deleteMany({ where: { optionId: id } }) const option = await prisma.option.update({ where: { id }, data: { name: input.name, type: input.type, values: { create: input.values.map(value => ({ value: value.value, price: value.price || 0 })) } }, include: { values: true } }) return option } catch (error) { console.error('Ошибка обновления опции:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить опцию') } }, // Клиенты createClient: async (_: unknown, { input, vehicles = [], discounts = [] }: { input: ClientInput; vehicles?: ClientVehicleInput[]; discounts?: ClientDiscountInput[] }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } // Генерируем номер клиента, если не указан let clientNumber = input.clientNumber if (!clientNumber) { const lastClient = await prisma.client.findFirst({ orderBy: { clientNumber: 'desc' } }) const lastNumber = lastClient ? parseInt(lastClient.clientNumber) : 100000 clientNumber = (lastNumber + 1).toString() } const client = await prisma.client.create({ data: { clientNumber, type: input.type, name: input.name, email: input.email, phone: input.phone, city: input.city, markup: input.markup, isConfirmed: input.isConfirmed ?? false, profileId: input.profileId, legalEntityType: input.legalEntityType, inn: input.inn, kpp: input.kpp, ogrn: input.ogrn, okpo: input.okpo, legalAddress: input.legalAddress, actualAddress: input.actualAddress, bankAccount: input.bankAccount, bankName: input.bankName, bankBik: input.bankBik, correspondentAccount: input.correspondentAccount, vehicles: { create: vehicles }, discounts: { create: discounts } }, include: { profile: true, vehicles: true, discounts: true } }) return client } catch (error) { console.error('Ошибка создания клиента:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать клиента') } }, updateClient: async (_: unknown, { id, input, vehicles = [], discounts = [] }: { id: string; input: ClientInput; vehicles?: ClientVehicleInput[]; discounts?: ClientDiscountInput[] }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } // Удаляем старые связанные данные await prisma.clientVehicle.deleteMany({ where: { clientId: id } }) await prisma.clientDiscount.deleteMany({ where: { clientId: id } }) const client = await prisma.client.update({ where: { id }, data: { type: input.type, name: input.name, email: input.email, phone: input.phone, city: input.city, markup: input.markup, isConfirmed: input.isConfirmed, profileId: input.profileId, legalEntityType: input.legalEntityType, inn: input.inn, kpp: input.kpp, ogrn: input.ogrn, okpo: input.okpo, legalAddress: input.legalAddress, actualAddress: input.actualAddress, bankAccount: input.bankAccount, bankName: input.bankName, bankBik: input.bankBik, correspondentAccount: input.correspondentAccount, vehicles: { create: vehicles }, discounts: { create: discounts } }, include: { profile: true, vehicles: true, discounts: true } }) return client } catch (error) { console.error('Ошибка обновления клиента:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить клиента') } }, deleteClient: async (_: unknown, { id }: { id: string }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } await prisma.client.delete({ where: { id } }) return true } catch (error) { console.error('Ошибка удаления клиента:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить клиента') } }, confirmClient: async (_: unknown, { id }: { id: string }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const client = await prisma.client.update({ where: { id }, data: { isConfirmed: true }, include: { profile: true, vehicles: true, discounts: true } }) return client } catch (error) { console.error('Ошибка подтверждения клиента:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось подтвердить клиента') } }, exportClients: async (_: unknown, { filter, search }: { filter?: ClientFilterInput; search?: string }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const where: Record = {} if (filter) { if (filter.type) { where.type = filter.type } if (filter.registeredFrom || filter.registeredTo) { where.createdAt = {} if (filter.registeredFrom) { (where.createdAt as Record).gte = filter.registeredFrom } if (filter.registeredTo) { (where.createdAt as Record).lte = filter.registeredTo } } if (filter.unconfirmed) { where.isConfirmed = false } if (filter.profileId) { where.profileId = filter.profileId } } if (search) { where.OR = [ { name: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } }, { phone: { contains: search, mode: 'insensitive' } }, { clientNumber: { contains: search, mode: 'insensitive' } } ] } const clients = await prisma.client.findMany({ where, include: { profile: true, vehicles: true, discounts: true }, orderBy: { createdAt: 'desc' } }) // Создаем CSV данные const csvData = clients.map(client => ({ id: client.id, clientNumber: client.clientNumber, type: client.type === 'INDIVIDUAL' ? 'Физ. лицо' : 'Юр. лицо', name: client.name, email: client.email || '', phone: client.phone, city: client.city || '', markup: client.markup || 0, isConfirmed: client.isConfirmed ? 'Да' : 'Нет', profile: client.profile?.name || '', legalEntityType: client.legalEntityType || '', inn: client.inn || '', kpp: client.kpp || '', ogrn: client.ogrn || '', okpo: client.okpo || '', legalAddress: client.legalAddress || '', actualAddress: client.actualAddress || '', bankAccount: client.bankAccount || '', bankName: client.bankName || '', bankBik: client.bankBik || '', correspondentAccount: client.correspondentAccount || '', vehicles: client.vehicles.map(v => `${v.brand || ''} ${v.model || ''} (${v.licensePlate || v.vin || v.frame || ''})` ).join('; '), createdAt: client.createdAt instanceof Date ? client.createdAt.toISOString() : client.createdAt })) // Создаем CSV строку const createCsvWriter = csvWriter.createObjectCsvStringifier({ header: [ { id: 'clientNumber', title: 'Номер клиента' }, { id: 'type', title: 'Тип' }, { id: 'name', title: 'Имя' }, { id: 'email', title: 'Email' }, { id: 'phone', title: 'Телефон' }, { id: 'city', title: 'Город' }, { id: 'markup', title: 'Наценка' }, { id: 'isConfirmed', title: 'Подтвержден' }, { id: 'profile', title: 'Профиль' }, { id: 'legalEntityType', title: 'Тип юр. лица' }, { id: 'inn', title: 'ИНН' }, { id: 'kpp', title: 'КПП' }, { id: 'ogrn', title: 'ОГРН' }, { id: 'okpo', title: 'ОКПО' }, { id: 'legalAddress', title: 'Юридический адрес' }, { id: 'actualAddress', title: 'Фактический адрес' }, { id: 'bankAccount', title: 'Расчетный счет' }, { id: 'bankName', title: 'Банк' }, { id: 'bankBik', title: 'БИК' }, { id: 'correspondentAccount', title: 'Корр. счет' }, { id: 'vehicles', title: 'Автомобили' }, { id: 'createdAt', title: 'Дата регистрации' } ] }) const csvString = createCsvWriter.getHeaderString() + createCsvWriter.stringifyRecords(csvData) const csvBuffer = Buffer.from(csvString, 'utf8') // Генерируем имя файла const timestamp = new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-') const filename = `clients-export-${timestamp}.csv` const key = generateFileKey(filename, 'exports') // Загружаем в S3 const uploadResult = await uploadBuffer(csvBuffer, key, 'text/csv') return { url: uploadResult.url, filename, count: clients.length } } catch (error) { console.error('Ошибка экспорта клиентов:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось экспортировать клиентов') } }, // Профили клиентов createClientProfile: async (_: unknown, { input }: { input: ClientProfileInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } // Генерируем код профиля, если не указан let code = input.code if (!code) { const lastProfile = await prisma.clientProfile.findFirst({ orderBy: { code: 'desc' } }) const lastNumber = lastProfile ? parseInt(lastProfile.code) : 1000000 code = (lastNumber + 1).toString() } const profile = await prisma.clientProfile.create({ data: { code, name: input.name, description: input.description, baseMarkup: input.baseMarkup, autoSendInvoice: input.autoSendInvoice ?? true, vinRequestModule: input.vinRequestModule ?? false, priceRangeMarkups: { create: input.priceRangeMarkups || [] }, orderDiscounts: { create: input.orderDiscounts || [] }, supplierMarkups: { create: input.supplierMarkups || [] }, brandMarkups: { create: input.brandMarkups || [] }, categoryMarkups: { create: input.categoryMarkups || [] }, excludedBrands: { create: (input.excludedBrands || []).map(brandName => ({ brandName })) }, excludedCategories: { create: (input.excludedCategories || []).map(categoryName => ({ categoryName })) }, paymentTypes: { create: input.paymentTypes || [] } }, include: { priceRangeMarkups: true, orderDiscounts: true, supplierMarkups: true, brandMarkups: true, categoryMarkups: true, excludedBrands: true, excludedCategories: true, paymentTypes: true } }) return profile } catch (error) { console.error('Ошибка создания профиля клиента:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать профиль клиента') } }, updateClientProfile: async (_: unknown, { id, input }: { id: string; input: ClientProfileInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } // Удаляем старые связанные данные await prisma.profilePriceRangeMarkup.deleteMany({ where: { profileId: id } }) await prisma.profileOrderDiscount.deleteMany({ where: { profileId: id } }) await prisma.profileSupplierMarkup.deleteMany({ where: { profileId: id } }) await prisma.profileBrandMarkup.deleteMany({ where: { profileId: id } }) await prisma.profileCategoryMarkup.deleteMany({ where: { profileId: id } }) await prisma.profileExcludedBrand.deleteMany({ where: { profileId: id } }) await prisma.profileExcludedCategory.deleteMany({ where: { profileId: id } }) await prisma.profilePaymentType.deleteMany({ where: { profileId: id } }) const profile = await prisma.clientProfile.update({ where: { id }, data: { name: input.name, description: input.description, baseMarkup: input.baseMarkup, autoSendInvoice: input.autoSendInvoice, vinRequestModule: input.vinRequestModule, priceRangeMarkups: { create: input.priceRangeMarkups || [] }, orderDiscounts: { create: input.orderDiscounts || [] }, supplierMarkups: { create: input.supplierMarkups || [] }, brandMarkups: { create: input.brandMarkups || [] }, categoryMarkups: { create: input.categoryMarkups || [] }, excludedBrands: { create: (input.excludedBrands || []).map(brandName => ({ brandName })) }, excludedCategories: { create: (input.excludedCategories || []).map(categoryName => ({ categoryName })) }, paymentTypes: { create: input.paymentTypes || [] } }, include: { priceRangeMarkups: true, orderDiscounts: true, supplierMarkups: true, brandMarkups: true, categoryMarkups: true, excludedBrands: true, excludedCategories: true, paymentTypes: true } }) return profile } catch (error) { console.error('Ошибка обновления профиля клиента:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить профиль клиента') } }, deleteClientProfile: async (_: unknown, { id }: { id: string }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } await prisma.clientProfile.delete({ where: { id } }) return true } catch (error) { console.error('Ошибка удаления профиля клиента:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить профиль клиента') } }, // Статусы клиентов createClientStatus: async (_: unknown, { input }: { input: ClientStatusInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const status = await prisma.clientStatus.create({ data: { name: input.name, color: input.color || '#6B7280', description: input.description } }) return status } catch (error) { console.error('Ошибка создания статуса клиента:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать статус клиента') } }, updateClientStatus: async (_: unknown, { id, input }: { id: string; input: ClientStatusInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const status = await prisma.clientStatus.update({ where: { id }, data: { name: input.name, color: input.color, description: input.description } }) return status } catch (error) { console.error('Ошибка обновления статуса клиента:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить статус клиента') } }, deleteClientStatus: async (_: unknown, { id }: { id: string }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } await prisma.clientStatus.delete({ where: { id } }) return true } catch (error) { console.error('Ошибка удаления статуса клиента:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить статус клиента') } }, // Скидки и промокоды createDiscount: async (_: unknown, { input }: { input: DiscountInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const discount = await prisma.discount.create({ data: { name: input.name, type: input.type, code: input.code, minOrderAmount: input.minOrderAmount || 0, discountType: input.discountType, discountValue: input.discountValue, isActive: input.isActive ?? true, validFrom: input.validFrom, validTo: input.validTo, profiles: { create: (input.profileIds || []).map(profileId => ({ profileId })) } }, include: { profiles: { include: { profile: true } } } }) return discount } catch (error) { console.error('Ошибка создания скидки:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать скидку') } }, updateDiscount: async (_: unknown, { id, input }: { id: string; input: DiscountInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } // Удаляем старые связи с профилями await prisma.discountProfile.deleteMany({ where: { discountId: id } }) const discount = await prisma.discount.update({ where: { id }, data: { name: input.name, type: input.type, code: input.code, minOrderAmount: input.minOrderAmount, discountType: input.discountType, discountValue: input.discountValue, isActive: input.isActive, validFrom: input.validFrom, validTo: input.validTo, profiles: { create: (input.profileIds || []).map(profileId => ({ profileId })) } }, include: { profiles: { include: { profile: true } } } }) return discount } catch (error) { console.error('Ошибка обновления скидки:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить скидку') } }, deleteDiscount: async (_: unknown, { id }: { id: string }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } await prisma.discount.delete({ where: { id } }) return true } catch (error) { console.error('Ошибка удаления скидки:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить скидку') } }, // Обновление баланса клиента updateClientBalance: async (_: unknown, { id, newBalance, comment }: { id: string; newBalance: number; comment?: string }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const client = await prisma.client.findUnique({ where: { id } }) if (!client) { throw new Error('Клиент не найден') } // Создаем запись в истории изменений баланса await prisma.clientBalanceHistory.create({ data: { clientId: id, userId: context.userId, oldValue: client.balance, newValue: newBalance, comment } }) // Обновляем баланс клиента const updatedClient = await prisma.client.update({ where: { id }, data: { balance: newBalance }, include: { profile: true, manager: true, vehicles: true, discounts: true, deliveryAddresses: true, contacts: true, contracts: true, legalEntities: { include: { bankDetails: true } }, bankDetails: { include: { legalEntity: true } }, balanceHistory: { include: { user: true }, orderBy: { createdAt: 'desc' } } } }) return updatedClient } catch (error) { console.error('Ошибка обновления баланса клиента:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить баланс клиента') } }, // Транспорт клиента createClientVehicle: async (_: unknown, { clientId, input }: { clientId: string; input: ClientVehicleInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const vehicle = await prisma.clientVehicle.create({ data: { clientId, name: input.name, vin: input.vin, frame: input.frame, licensePlate: input.licensePlate, brand: input.brand, model: input.model, modification: input.modification, year: input.year, mileage: input.mileage, comment: input.comment } }) return vehicle } catch (error) { console.error('Ошибка создания транспорта:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать транспорт') } }, updateClientVehicle: async (_: unknown, { id, input }: { id: string; input: ClientVehicleInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const vehicle = await prisma.clientVehicle.update({ where: { id }, data: { name: input.name, vin: input.vin, frame: input.frame, licensePlate: input.licensePlate, brand: input.brand, model: input.model, modification: input.modification, year: input.year, mileage: input.mileage, comment: input.comment } }) return vehicle } catch (error) { console.error('Ошибка обновления транспорта:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить транспорт') } }, // Адреса доставки createClientDeliveryAddress: async (_: unknown, { clientId, input }: { clientId: string; input: ClientDeliveryAddressInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const address = await prisma.clientDeliveryAddress.create({ data: { clientId, name: input.name, address: input.address, deliveryType: input.deliveryType, comment: input.comment } }) return address } catch (error) { console.error('Ошибка создания адреса доставки:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать адрес доставки') } }, updateClientDeliveryAddress: async (_: unknown, { id, input }: { id: string; input: ClientDeliveryAddressInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const address = await prisma.clientDeliveryAddress.update({ where: { id }, data: { name: input.name, address: input.address, deliveryType: input.deliveryType, comment: input.comment, // Дополнительные поля для курьерской доставки entrance: input.entrance, floor: input.floor, apartment: input.apartment, intercom: input.intercom, deliveryTime: input.deliveryTime, contactPhone: input.contactPhone } }) return address } catch (error) { console.error('Ошибка обновления адреса доставки:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить адрес доставки') } }, deleteClientDeliveryAddress: async (_: unknown, { id }: { id: string }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } await prisma.clientDeliveryAddress.delete({ where: { id } }) return true } catch (error) { console.error('Ошибка удаления адреса доставки:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить адрес доставки') } }, // Контакты клиента createClientContact: async (_: unknown, { clientId, input }: { clientId: string; input: ClientContactInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const contact = await prisma.clientContact.create({ data: { clientId, phone: input.phone, email: input.email, comment: input.comment } }) return contact } catch (error) { console.error('Ошибка создания контакта:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать контакт') } }, updateClientContact: async (_: unknown, { id, input }: { id: string; input: ClientContactInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const contact = await prisma.clientContact.update({ where: { id }, data: { phone: input.phone, email: input.email, comment: input.comment } }) return contact } catch (error) { console.error('Ошибка обновления контакта:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить контакт') } }, deleteClientContact: async (_: unknown, { id }: { id: string }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } await prisma.clientContact.delete({ where: { id } }) return true } catch (error) { console.error('Ошибка удаления контакта:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить контакт') } }, // Договоры createClientContract: async (_: unknown, { clientId, input }: { clientId: string; input: ClientContractInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const contract = await prisma.clientContract.create({ data: { clientId, contractNumber: input.contractNumber, contractDate: input.contractDate || new Date(), name: input.name, ourLegalEntity: input.ourLegalEntity || '', clientLegalEntity: input.clientLegalEntity || '', balance: input.balance || 0, currency: input.currency || 'RUB', isActive: input.isActive ?? true, isDefault: input.isDefault ?? false, contractType: input.contractType || 'STANDARD', relationship: input.relationship || 'DIRECT', paymentDelay: input.paymentDelay ?? false, creditLimit: input.creditLimit, delayDays: input.delayDays, fileUrl: input.fileUrl } }) return contract } catch (error) { console.error('Ошибка создания договора:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать договор') } }, updateClientContract: async (_: unknown, { id, input }: { id: string; input: ClientContractInput }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } const contract = await prisma.clientContract.update({ where: { id }, data: { contractNumber: input.contractNumber, contractDate: input.contractDate, name: input.name, ourLegalEntity: input.ourLegalEntity, clientLegalEntity: input.clientLegalEntity, balance: input.balance, currency: input.currency, isActive: input.isActive, isDefault: input.isDefault, contractType: input.contractType, relationship: input.relationship, paymentDelay: input.paymentDelay, creditLimit: input.creditLimit, delayDays: input.delayDays, fileUrl: input.fileUrl } }) return contract } catch (error) { console.error('Ошибка обновления договора:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить договор') } }, deleteClientContract: async (_: unknown, { id }: { id: string }, context: Context) => { try { if (!context.userId) { throw new Error('Пользователь не авторизован') } await prisma.clientContract.delete({ where: { id } }) return true } catch (error) { console.error('Ошибка удаления договора:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить договор') } }, updateContractBalance: async (_: unknown, { contractId, amount, comment }: { contractId: string; amount: number; comment?: string }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.userId) { throw new Error('Пользователь не авторизован') } // Находим договор const contract = await prisma.clientContract.findUnique({ where: { id: contractId } }) if (!contract) { throw new Error('Договор не найден') } // Обновляем баланс договора const newBalance = contract.balance + amount const updatedContract = await prisma.clientContract.update({ where: { id: contractId }, data: { balance: newBalance } }) // Создаем запись в истории изменений баланса клиента await prisma.clientBalanceHistory.create({ data: { clientId: contract.clientId, userId: actualContext.userId, oldValue: contract.balance, newValue: newBalance, comment: comment || `Пополнение баланса договора ${contract.contractNumber} на ${amount} ${contract.currency}` } }) return updatedContract } catch (error) { console.error('Ошибка обновления баланса договора:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить баланс договора') } }, // Счета на пополнение баланса createBalanceInvoice: async (_: unknown, { contractId, amount }: { contractId: string; amount: number }, context: Context) => { try { console.log('🔍 createBalanceInvoice: начало выполнения') console.log('📋 createBalanceInvoice: contractId:', contractId, 'amount:', amount) const actualContext = context || getContext() console.log('🔑 createBalanceInvoice: контекст:', { clientId: actualContext.clientId, userId: actualContext.userId, userRole: actualContext.userRole }) if (!actualContext.clientId) { console.log('❌ createBalanceInvoice: клиент не авторизован') throw new Error('Пользователь не авторизован') } // Находим договор и проверяем что он принадлежит клиенту console.log('🔍 createBalanceInvoice: поиск договора:', contractId) const contract = await prisma.clientContract.findUnique({ where: { id: contractId }, include: { client: { include: { legalEntities: true } } } }) if (!contract) { console.log('❌ createBalanceInvoice: договор не найден') throw new Error('Договор не найден') } console.log('📋 createBalanceInvoice: найден договор:', { id: contract.id, contractNumber: contract.contractNumber, clientId: contract.clientId, isActive: contract.isActive }) if (contract.clientId !== actualContext.clientId) { console.log('❌ createBalanceInvoice: недостаточно прав. Договор принадлежит:', contract.clientId, 'а запрашивает:', actualContext.clientId) throw new Error('Недостаточно прав') } if (!contract.isActive) { console.log('❌ createBalanceInvoice: договор неактивен') throw new Error('Договор неактивен') } if (amount <= 0) { console.log('❌ createBalanceInvoice: неправильная сумма:', amount) throw new Error('Сумма должна быть больше 0') } console.log('✅ createBalanceInvoice: все проверки пройдены, создаем счет') // Импортируем сервис генерации счетов const { InvoiceService } = await import('../invoice-service') // Находим юридическое лицо клиента для этого договора const clientLegalEntity = contract.client.legalEntities.find(le => le.shortName === contract.clientLegalEntity || le.fullName === contract.clientLegalEntity ) console.log('🏢 createBalanceInvoice: юридическое лицо:', clientLegalEntity?.shortName || 'не найдено') // Создаем данные для счета const invoiceData = { contractId: contract.id, amount, currency: contract.currency, invoiceNumber: '', // будет сгенерирован в сервисе contractNumber: contract.contractNumber, clientName: clientLegalEntity?.shortName || contract.client.name, clientInn: clientLegalEntity?.inn } // Генерируем номер счета const invoiceNumber = InvoiceService.generateInvoiceNumber() const expiresAt = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000) // +3 дня console.log('📄 createBalanceInvoice: создаем счет с номером:', invoiceNumber) // Сохраняем счет в базу данных const balanceInvoice = await prisma.balanceInvoice.create({ data: { contractId: contract.id, amount, currency: contract.currency, invoiceNumber, qrCode: '', // Заполним позже expiresAt, status: 'PENDING' }, include: { contract: { include: { client: true } } } }) console.log('✅ createBalanceInvoice: счет создан успешно:', balanceInvoice.id) return balanceInvoice } catch (error) { console.error('❌ createBalanceInvoice: ошибка создания счета на пополнение баланса:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать счет на пополнение баланса') } }, updateInvoiceStatus: async (_: any, { invoiceId, status }: { invoiceId: string; status: string }, context: any) => { console.log('updateInvoiceStatus резолвер вызван:', { invoiceId, status }); if (!context.userId || context.userRole !== 'ADMIN') { throw new Error('Доступ запрещен. Требуются права администратора.'); } try { const updatedInvoice = await prisma.balanceInvoice.update({ where: { id: invoiceId }, data: { status: status as any, updatedAt: new Date() }, include: { contract: { include: { client: { include: { legalEntities: true } } } } } }); // Если статус изменился на PAID, пополняем баланс if (status === 'PAID') { await prisma.clientContract.update({ where: { id: updatedInvoice.contractId }, data: { balance: { increment: updatedInvoice.amount } } }); console.log(`✅ Баланс пополнен на ${updatedInvoice.amount} руб. для договора ${updatedInvoice.contractId}`); } return updatedInvoice; } catch (error) { console.error('Ошибка обновления статуса счета:', error); throw new Error('Не удалось обновить статус счета'); } }, getInvoicePDF: async (_: any, { invoiceId }: { invoiceId: string }, context: any) => { console.log('🔍 Получение PDF счета через GraphQL:', invoiceId); try { // Получаем счет из базы данных const invoice = await prisma.balanceInvoice.findUnique({ where: { id: invoiceId }, include: { contract: { include: { client: { include: { legalEntities: true } } } } } }); if (!invoice) { return { success: false, error: 'Счет не найден' }; } // Проверяем авторизацию let hasAccess = false; console.log('🔍 Проверка доступа:', { userId: context.userId, userRole: context.userRole, clientId: context.clientId, invoiceClientId: invoice.contract.clientId }); // Админ имеет доступ ко всем счетам if (context.userId && context.userRole === 'ADMIN') { hasAccess = true; console.log('✅ Доступ предоставлен администратору'); } // Клиент имеет доступ только к своим счетам else if (context.clientId && context.clientId === invoice.contract.clientId) { hasAccess = true; console.log('✅ Доступ предоставлен владельцу счета'); } if (!hasAccess) { return { success: false, error: 'Доступ запрещен' }; } // Преобразуем данные для генерации PDF const legalEntity = invoice.contract.client.legalEntities[0]; const invoiceData = { invoiceNumber: invoice.invoiceNumber, amount: invoice.amount, clientName: legalEntity?.shortName || invoice.contract.client.name, clientInn: legalEntity?.inn, clientAddress: legalEntity?.legalAddress, contractNumber: invoice.contract.contractNumber, description: `Пополнение баланса по договору ${invoice.contract.contractNumber}`, dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 дней }; // Генерируем PDF const pdfBuffer = await InvoiceService.generatePDF(invoiceData); const pdfBase64 = pdfBuffer.toString('base64'); const filename = `Счет-${invoice.invoiceNumber}.pdf`; console.log('✅ PDF успешно сгенерирован'); return { success: true, pdfBase64, filename }; } catch (error) { console.error('❌ Ошибка генерации PDF:', error); return { success: false, error: 'Ошибка генерации PDF: ' + (error as Error).message }; } }, // Юридические лица createClientLegalEntity: async (_: unknown, { clientId, input }: { clientId: string; input: ClientLegalEntityInput }, context: Context) => { try { const actualContext = context || getContext() // Проверяем авторизацию - либо админ CMS, либо клиент if (!actualContext.userId && !actualContext.clientId) { throw new Error('Пользователь не авторизован') } // Если это клиент, он может создавать только свои юр. лица if (actualContext.clientId && clientId !== actualContext.clientId) { throw new Error('Недостаточно прав') } const legalEntity = await prisma.clientLegalEntity.create({ data: { clientId, shortName: input.shortName, fullName: input.fullName || input.shortName, form: input.form || 'ООО', legalAddress: input.legalAddress || '', actualAddress: input.actualAddress, taxSystem: input.taxSystem || 'УСН', responsiblePhone: input.responsiblePhone, responsiblePosition: input.responsiblePosition, responsibleName: input.responsibleName, accountant: input.accountant, signatory: input.signatory, registrationReasonCode: input.registrationReasonCode, ogrn: input.ogrn, inn: input.inn, vatPercent: input.vatPercent || 20 }, include: { bankDetails: true } }) return legalEntity } catch (error) { console.error('Ошибка создания юридического лица:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать юридическое лицо') } }, updateClientLegalEntity: async (_: unknown, { id, input }: { id: string; input: ClientLegalEntityInput }, context: Context) => { try { // Если контекст не передан как параметр, получаем из глобальной переменной const actualContext = context || getContext() // Проверяем авторизацию - либо админ CMS, либо клиент if (!actualContext.userId && !actualContext.clientId) { throw new Error('Пользователь не авторизован') } // Если это клиент, проверяем что юр. лицо принадлежит ему if (actualContext.clientId) { const existingEntity = await prisma.clientLegalEntity.findUnique({ where: { id } }) if (!existingEntity || existingEntity.clientId !== actualContext.clientId) { throw new Error('Недостаточно прав') } } const legalEntity = await prisma.clientLegalEntity.update({ where: { id }, data: { shortName: input.shortName, fullName: input.fullName, form: input.form, legalAddress: input.legalAddress, actualAddress: input.actualAddress, taxSystem: input.taxSystem, responsiblePhone: input.responsiblePhone, responsiblePosition: input.responsiblePosition, responsibleName: input.responsibleName, accountant: input.accountant, signatory: input.signatory, registrationReasonCode: input.registrationReasonCode, ogrn: input.ogrn, inn: input.inn, vatPercent: input.vatPercent }, include: { bankDetails: true } }) return legalEntity } catch (error) { console.error('Ошибка обновления юридического лица:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить юридическое лицо') } }, deleteClientLegalEntity: async (_: unknown, { id }: { id: string }, context: Context) => { try { const actualContext = context || getContext() // Проверяем авторизацию - либо админ CMS, либо клиент if (!actualContext.userId && !actualContext.clientId) { throw new Error('Пользователь не авторизован') } // Если это клиент, проверяем что юр. лицо принадлежит ему if (actualContext.clientId) { const existingEntity = await prisma.clientLegalEntity.findUnique({ where: { id } }) if (!existingEntity || existingEntity.clientId !== actualContext.clientId) { throw new Error('Недостаточно прав') } } await prisma.clientLegalEntity.delete({ where: { id } }) return true } catch (error) { console.error('Ошибка удаления юридического лица:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить юридическое лицо') } }, // Банковские реквизиты createClientBankDetails: async (_: unknown, { legalEntityId, input }: { legalEntityId: string; input: ClientBankDetailsInput }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.userId && !actualContext.clientId) { throw new Error('Пользователь не авторизован') } // Получаем clientId из legalEntity const legalEntity = await prisma.clientLegalEntity.findUnique({ where: { id: legalEntityId } }) if (!legalEntity) { throw new Error('Юридическое лицо не найдено') } const bankDetails = await prisma.clientBankDetails.create({ data: { clientId: legalEntity.clientId, legalEntityId: legalEntityId, name: input.name, accountNumber: input.accountNumber, bankName: input.bankName, bik: input.bik, correspondentAccount: input.correspondentAccount }, include: { legalEntity: true } }) return bankDetails } catch (error) { console.error('Ошибка создания банковских реквизитов:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать банковские реквизиты') } }, updateClientBankDetails: async (_: unknown, { id, input, legalEntityId }: { id: string; input: ClientBankDetailsInput; legalEntityId?: string }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.userId && !actualContext.clientId) { throw new Error('Пользователь не авторизован') } // Если передан legalEntityId, проверяем что юридическое лицо существует if (legalEntityId) { const legalEntity = await prisma.clientLegalEntity.findUnique({ where: { id: legalEntityId } }) if (!legalEntity) { throw new Error('Юридическое лицо не найдено') } } const bankDetails = await prisma.clientBankDetails.update({ where: { id }, data: { name: input.name, accountNumber: input.accountNumber, bankName: input.bankName, bik: input.bik, correspondentAccount: input.correspondentAccount, ...(legalEntityId && { legalEntityId }) }, include: { legalEntity: true } }) return bankDetails } catch (error) { console.error('Ошибка обновления банковских реквизитов:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить банковские реквизиты') } }, deleteClientBankDetails: async (_: unknown, { id }: { id: string }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.userId && !actualContext.clientId) { throw new Error('Пользователь не авторизован') } await prisma.clientBankDetails.delete({ where: { id } }) return true } catch (error) { console.error('Ошибка удаления банковских реквизитов:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить банковские реквизиты') } }, // Авторизация клиентов checkClientByPhone: async (_: unknown, { phone }: { phone: string }) => { try { const client = await prisma.client.findFirst({ where: { phone }, include: { profile: true } }) const sessionId = Math.random().toString(36).substring(7) return { exists: !!client, client, sessionId } } catch (error) { console.error('Ошибка проверки клиента по телефону:', error) throw new Error('Не удалось проверить клиента') } }, sendSMSCode: async (_: unknown, { phone, sessionId }: { phone: string; sessionId?: string }) => { try { // Используем импортированные сервисы const finalSessionId = sessionId || Math.random().toString(36).substring(7) // Проверяем, есть ли уже активный код для этого номера и сессии if (smsCodeStore.hasActiveCode(phone, finalSessionId)) { const ttl = smsCodeStore.getCodeTTL(phone, finalSessionId) console.log(`У номера ${phone} уже есть активный код, осталось ${ttl} секунд`) return { success: true, sessionId: finalSessionId, message: `Код уже отправлен. Попробуйте через ${ttl} секунд.` } } // Генерируем 5-значный код const code = Math.floor(10000 + Math.random() * 90000).toString() // Сохраняем код в хранилище smsCodeStore.saveCode(phone, code, finalSessionId) // Отправляем SMS через Билайн API const smsResult = await smsService.sendVerificationCode(phone, code) if (smsResult.success) { return { success: true, sessionId: finalSessionId, messageId: smsResult.messageId, message: 'SMS код отправлен' } } else { // Если SMS не отправилось в production - бросаем ошибку if (process.env.NODE_ENV !== 'development') { throw new Error(`Не удалось отправить SMS: ${smsResult.error}`) } // В development режиме возвращаем успех и показываем код return { success: true, sessionId: finalSessionId, message: 'SMS отправлен (dev mode)', code // Только в dev режиме! } } } catch (error) { console.error('Ошибка отправки SMS:', error) throw new Error('Не удалось отправить SMS код') } }, verifyCode: async (_: unknown, { phone, code, sessionId }: { phone: string; code: string; sessionId: string }) => { try { console.log(`Верификация кода для ${phone}, код: ${code}, sessionId: ${sessionId}`) // Проверяем код через наше хранилище const verification = smsCodeStore.verifyCode(phone, code, sessionId) if (!verification.valid) { console.log(`Код неверный: ${verification.error}`) throw new Error(verification.error || 'Неверный код') } console.log('Код верифицирован успешно') // Ищем клиента в базе const client = await prisma.client.findFirst({ where: { phone }, include: { profile: true } }) console.log(`Клиент найден: ${!!client}`) if (client) { // Если клиент существует - авторизуем его console.log(`Авторизуем существующего клиента: ${client.id}`) const token = `client_${client.id}_${Date.now()}` return { success: true, client, token } } else { // Если клиент не существует - возвращаем успех без клиента // Это означает что нужно будет перейти к регистрации console.log('Клиент не найден, возвращаем success с client: null для регистрации') return { success: true, client: null, token: null } } } catch (error) { console.error('Ошибка верификации кода:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось верифицировать код') } }, registerNewClient: async (_: unknown, { phone, name }: { phone: string; name: string; sessionId: string }) => { try { // Проверяем, что клиент еще не существует const existingClient = await prisma.client.findFirst({ where: { phone } }) if (existingClient) { throw new Error('Клиент с таким номером уже существует') } // Разбиваем имя на имя и фамилию const nameParts = name.trim().split(' ') const firstName = nameParts[0] || name const lastName = nameParts.slice(1).join(' ') || '' const fullName = lastName ? `${firstName} ${lastName}` : firstName // Создаем нового клиента const client = await prisma.client.create({ data: { clientNumber: `CL${Date.now()}`, type: 'INDIVIDUAL', name: fullName, phone, isConfirmed: true, balance: 0, emailNotifications: false, smsNotifications: false, pushNotifications: false }, include: { profile: true } }) // Создаем простой токен const token = `client_${client.id}_${Date.now()}` return { success: true, client, token } } catch (error) { console.error('Ошибка регистрации клиента:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось зарегистрировать клиента') } }, // Мутации для гаража клиентов createUserVehicle: async (_: unknown, { input }: { input: ClientVehicleInput }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } // Проверяем существует ли клиент, если нет - создаем только для временных клиентов let client = await prisma.client.findUnique({ where: { id: actualContext.clientId } }) if (!client) { if (actualContext.clientId.startsWith('client_') && actualContext.clientId.length > 30) { client = await prisma.client.create({ data: { id: actualContext.clientId, clientNumber: `CLIENT_${Date.now()}`, type: 'INDIVIDUAL', name: 'Гость', phone: '+7', isConfirmed: false } }) } else { throw new Error('Клиент не найден в системе') } } const vehicle = await prisma.clientVehicle.create({ data: { clientId: actualContext.clientId, name: input.name, vin: input.vin, frame: input.frame, licensePlate: input.licensePlate, brand: input.brand, model: input.model, modification: input.modification, year: input.year, mileage: input.mileage, comment: input.comment } }) return vehicle } catch (error) { console.error('Ошибка создания автомобиля:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать автомобиль') } }, updateUserVehicle: async (_: unknown, { id, input }: { id: string; input: ClientVehicleInput }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } // Проверяем, что автомобиль принадлежит клиенту const existingVehicle = await prisma.clientVehicle.findFirst({ where: { id, clientId: actualContext.clientId } }) if (!existingVehicle) { throw new Error('Автомобиль не найден') } const vehicle = await prisma.clientVehicle.update({ where: { id }, data: { name: input.name, vin: input.vin, frame: input.frame, licensePlate: input.licensePlate, brand: input.brand, model: input.model, modification: input.modification, year: input.year, mileage: input.mileage, comment: input.comment } }) return vehicle } catch (error) { console.error('Ошибка обновления автомобиля:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить автомобиль') } }, deleteUserVehicle: async (_: unknown, { id }: { id: string }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } // Проверяем, что автомобиль принадлежит клиенту const existingVehicle = await prisma.clientVehicle.findFirst({ where: { id, clientId: actualContext.clientId } }) if (!existingVehicle) { throw new Error('Автомобиль не найден') } await prisma.clientVehicle.delete({ where: { id } }) return true } catch (error) { console.error('Ошибка удаления автомобиля:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить автомобиль') } }, addVehicleFromSearch: async (_: unknown, { vin, comment }: { vin: string; comment?: string }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } // Определяем clientId, убирая префикс client_ если он есть const clientIdParts = actualContext.clientId.split('_') let clientId = actualContext.clientId if (clientIdParts.length >= 3) { clientId = clientIdParts[1] } else if (clientIdParts.length === 2) { clientId = clientIdParts[1] } // Ищем информацию об автомобиле в истории поиска const searchHistoryItem = await prisma.partsSearchHistory.findFirst({ where: { clientId, searchQuery: vin, searchType: 'VIN' }, orderBy: { createdAt: 'desc' } // Берем самую свежую запись }) // Создаем название автомобиля на основе данных из истории let vehicleName = `Автомобиль ${vin}` let vehicleBrand: string | undefined = undefined let vehicleModel: string | undefined = undefined let vehicleYear: number | undefined = undefined if (searchHistoryItem && (searchHistoryItem.vehicleBrand || searchHistoryItem.vehicleModel)) { vehicleBrand = searchHistoryItem.vehicleBrand || undefined vehicleModel = searchHistoryItem.vehicleModel || undefined vehicleYear = searchHistoryItem.vehicleYear || undefined // Формируем красивое название if (vehicleBrand && vehicleModel) { vehicleName = `${vehicleBrand} ${vehicleModel}` } else if (vehicleBrand) { vehicleName = vehicleBrand } else if (vehicleModel) { vehicleName = vehicleModel } } // Создаем автомобиль из результата поиска с полной информацией const vehicle = await prisma.clientVehicle.create({ data: { clientId: actualContext.clientId, name: vehicleName, vin, brand: vehicleBrand, model: vehicleModel, year: vehicleYear, comment: comment || '' } }) return vehicle } catch (error) { console.error('Ошибка добавления автомобиля из поиска:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось добавить автомобиль из поиска') } }, deleteSearchHistoryItem: async (_: unknown, { id }: { id: string }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } // Определяем clientId, убирая префикс client_ если он есть const clientIdParts = actualContext.clientId.split('_') let clientId = actualContext.clientId if (clientIdParts.length >= 3) { clientId = clientIdParts[1] } else if (clientIdParts.length === 2) { clientId = clientIdParts[1] } console.log('deleteSearchHistoryItem: удаление VIN записи', id, 'для клиента', clientId) // Проверяем, что запись принадлежит клиенту и имеет тип VIN const existingItem = await prisma.partsSearchHistory.findFirst({ where: { id, clientId, searchType: 'VIN' // Удаляем только VIN записи } }) if (!existingItem) { throw new Error('VIN запись не найдена или не принадлежит клиенту') } await prisma.partsSearchHistory.delete({ where: { id } }) console.log('deleteSearchHistoryItem: VIN запись удалена') return true } catch (error) { console.error('Ошибка удаления из истории VIN поиска:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить элемент из истории поиска') } }, // Мутации для истории поиска запчастей deletePartsSearchHistoryItem: async (_: unknown, { id }: { id: string }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } // Определяем clientId, убирая префикс client_ если он есть const clientIdParts = actualContext.clientId.split('_') let clientId = actualContext.clientId if (clientIdParts.length >= 3) { clientId = clientIdParts[1] } else if (clientIdParts.length === 2) { clientId = clientIdParts[1] } console.log('deletePartsSearchHistoryItem: удаление записи', id, 'для клиента', clientId) // Проверяем, что запись принадлежит клиенту const existingItem = await prisma.partsSearchHistory.findFirst({ where: { id, clientId } }) if (!existingItem) { throw new Error('Запись не найдена или не принадлежит клиенту') } await prisma.partsSearchHistory.delete({ where: { id } }) console.log('deletePartsSearchHistoryItem: запись удалена') return true } catch (error) { console.error('Ошибка удаления записи истории поиска запчастей:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить запись из истории поиска') } }, clearPartsSearchHistory: async (_: unknown, __: unknown, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } // Определяем clientId, убирая префикс client_ если он есть const clientIdParts = actualContext.clientId.split('_') let clientId = actualContext.clientId if (clientIdParts.length >= 3) { clientId = clientIdParts[1] } else if (clientIdParts.length === 2) { clientId = clientIdParts[1] } console.log('clearPartsSearchHistory: очистка истории для клиента', clientId) const deleteResult = await prisma.partsSearchHistory.deleteMany({ where: { clientId } }) console.log(`clearPartsSearchHistory: удалено ${deleteResult.count} записей`) return true } catch (error) { console.error('Ошибка очистки истории поиска запчастей:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось очистить историю поиска') } }, createPartsSearchHistoryItem: async (_: unknown, { input }: { input: any }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } // Определяем clientId, убирая префикс client_ если он есть const clientIdParts = actualContext.clientId.split('_') let clientId = actualContext.clientId if (clientIdParts.length >= 3) { clientId = clientIdParts[1] } else if (clientIdParts.length === 2) { clientId = clientIdParts[1] } console.log('createPartsSearchHistoryItem: создание записи для клиента', clientId) // Проверяем существует ли клиент const client = await prisma.client.findUnique({ where: { id: clientId } }) if (!client) { throw new Error('Клиент не найден') } const historyItem = await prisma.partsSearchHistory.create({ data: { clientId, searchQuery: input.searchQuery, searchType: input.searchType, brand: input.brand, articleNumber: input.articleNumber, vehicleBrand: input.vehicleBrand, vehicleModel: input.vehicleModel, vehicleYear: input.vehicleYear, resultCount: input.resultCount || 0 } }) console.log('createPartsSearchHistoryItem: запись создана', historyItem.id) return { id: historyItem.id, searchQuery: historyItem.searchQuery, searchType: historyItem.searchType, brand: historyItem.brand, articleNumber: historyItem.articleNumber, vehicleInfo: historyItem.vehicleBrand || historyItem.vehicleModel || historyItem.vehicleYear ? { brand: historyItem.vehicleBrand, model: historyItem.vehicleModel, year: historyItem.vehicleYear } : null, resultCount: historyItem.resultCount, createdAt: historyItem.createdAt instanceof Date ? historyItem.createdAt.toISOString() : historyItem.createdAt } } catch (error) { console.error('Ошибка создания записи истории поиска запчастей:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать запись истории поиска') } }, createVehicleFromVin: async (_: unknown, { vin, comment }: { vin: string; comment?: string }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } console.log('Создание автомобиля из VIN:', vin) // Проверяем существует ли клиент, если нет - создаем только если это действительно новый клиент let client = await prisma.client.findUnique({ where: { id: actualContext.clientId } }) if (!client) { // Проверяем, не является ли это токеном временного клиента // Временные клиенты имеют длинные ID типа "client_cmbzedr1k0000rqz5phpvgpxc" if (actualContext.clientId.startsWith('client_') && actualContext.clientId.length > 30) { console.log('Создаем временного клиента:', actualContext.clientId) client = await prisma.client.create({ data: { id: actualContext.clientId, clientNumber: `CLIENT_${Date.now()}`, type: 'INDIVIDUAL', name: 'Гость', phone: '+7', isConfirmed: false } }) console.log('Временный клиент создан:', client.id) } else { throw new Error('Клиент не найден в системе') } } // Ищем автомобиль в Laximo let laximoData: any[] = [] try { laximoData = await laximoService.findVehicleGlobal(vin) console.log('Данные из Laximo:', laximoData) } catch (laximoError) { console.log('Ошибка поиска в Laximo:', laximoError) // Продолжаем выполнение, создадим автомобиль без данных Laximo } // Выбираем первый результат из Laximo или создаем базовые данные let vehicleData = { clientId: actualContext.clientId, vin: vin.toUpperCase(), comment: comment || '', name: `Автомобиль ${vin}`, brand: null as string | null, model: null as string | null, modification: null as string | null, year: null as number | null } if (laximoData && laximoData.length > 0) { const firstResult = laximoData[0] vehicleData = { ...vehicleData, name: firstResult.name || `${firstResult.brand || ''} ${firstResult.model || ''}`.trim() || vehicleData.name, brand: firstResult.brand || null, model: firstResult.model || null, modification: firstResult.modification || null, year: firstResult.year ? parseInt(firstResult.year, 10) : null } } const vehicle = await prisma.clientVehicle.create({ data: vehicleData }) console.log('Автомобиль создан:', vehicle) return vehicle } catch (error) { console.error('Ошибка создания автомобиля из VIN:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать автомобиль из VIN') } }, // Обновление данных авторизованного клиента updateClientMe: async (_: unknown, { input }: { input: ClientInput }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } const updatedClient = await prisma.client.update({ where: { id: actualContext.clientId }, data: input, include: { legalEntities: true, profile: true, vehicles: true, deliveryAddresses: true, contacts: true, contracts: true, bankDetails: true, discounts: true } }) return updatedClient } catch (error) { console.error('Ошибка обновления данных клиента:', error) throw new Error('Не удалось обновить данные клиента') } }, // Создание юр. лица для авторизованного клиента createClientLegalEntityMe: async (_: unknown, { input }: { input: ClientLegalEntityInput }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } const legalEntity = await prisma.clientLegalEntity.create({ data: { clientId: actualContext.clientId, shortName: input.shortName, fullName: input.fullName || input.shortName, form: input.form || 'ООО', legalAddress: input.legalAddress || '', actualAddress: input.actualAddress, taxSystem: input.taxSystem || 'УСН', responsiblePhone: input.responsiblePhone, responsiblePosition: input.responsiblePosition, responsibleName: input.responsibleName, accountant: input.accountant, signatory: input.signatory, registrationReasonCode: input.registrationReasonCode, ogrn: input.ogrn, inn: input.inn, vatPercent: input.vatPercent || 20 }, include: { bankDetails: true } }) return legalEntity } catch (error) { console.error('Ошибка создания юридического лица:', error) throw new Error('Не удалось создать юридическое лицо') } }, // Адреса доставки для авторизованного клиента createClientDeliveryAddressMe: async (_: unknown, { input }: { input: ClientDeliveryAddressInput }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } const address = await prisma.clientDeliveryAddress.create({ data: { clientId: actualContext.clientId, name: input.name, address: input.address, deliveryType: input.deliveryType, comment: input.comment, // Дополнительные поля для курьерской доставки entrance: input.entrance, floor: input.floor, apartment: input.apartment, intercom: input.intercom, deliveryTime: input.deliveryTime, contactPhone: input.contactPhone } }) return address } catch (error) { console.error('Ошибка создания адреса доставки:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать адрес доставки') } }, updateClientDeliveryAddressMe: async (_: unknown, { id, input }: { id: string; input: ClientDeliveryAddressInput }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } // Проверяем, что адрес принадлежит текущему клиенту const existingAddress = await prisma.clientDeliveryAddress.findUnique({ where: { id } }) if (!existingAddress || existingAddress.clientId !== actualContext.clientId) { throw new Error('Адрес не найден или недостаточно прав') } const address = await prisma.clientDeliveryAddress.update({ where: { id }, data: { name: input.name, address: input.address, deliveryType: input.deliveryType, comment: input.comment, // Дополнительные поля для курьерской доставки entrance: input.entrance, floor: input.floor, apartment: input.apartment, intercom: input.intercom, deliveryTime: input.deliveryTime, contactPhone: input.contactPhone } }) return address } catch (error) { console.error('Ошибка обновления адреса доставки:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось обновить адрес доставки') } }, deleteClientDeliveryAddressMe: async (_: unknown, { id }: { id: string }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } // Проверяем, что адрес принадлежит текущему клиенту const existingAddress = await prisma.clientDeliveryAddress.findUnique({ where: { id } }) if (!existingAddress || existingAddress.clientId !== actualContext.clientId) { throw new Error('Адрес не найден или недостаточно прав') } await prisma.clientDeliveryAddress.delete({ where: { id } }) return true } catch (error) { console.error('Ошибка удаления адреса доставки:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить адрес доставки') } }, // Заказы и платежи createOrder: async (_: unknown, { input }: { input: CreateOrderInput }, context: Context) => { try { const actualContext = context || getContext() // Проверяем наличие товаров из нашего склада и резервируем их const internalItems = input.items.filter(item => item.productId) // Товары с productId - это наши товары if (internalItems.length > 0) { console.log('createOrder: проверяем наличие внутренних товаров:', internalItems.length) // Проверяем наличие каждого товара for (const item of internalItems) { const product = await prisma.product.findUnique({ where: { id: item.productId! } }) if (!product) { throw new Error(`Товар с ID ${item.productId} не найден`) } if (product.stock < item.quantity) { throw new Error(`Недостаточно товара "${product.name}" в наличии. Доступно: ${product.stock}, запрошено: ${item.quantity}`) } } console.log('createOrder: все товары доступны, резервируем') // Резервируем товары (вычитаем из наличия) for (const item of internalItems) { await prisma.product.update({ where: { id: item.productId! }, data: { stock: { decrement: item.quantity } } }) console.log(`createOrder: зарезервировано ${item.quantity} шт. товара ${item.productId}`) } } // Генерируем номер заказа const orderNumber = `ORD-${Date.now()}-${Math.random().toString(36).substr(2, 9).toUpperCase()}` // Вычисляем общую сумму const totalAmount = input.items.reduce((sum, item) => sum + (item.price * item.quantity), 0) // Определяем clientId, убирая префикс client_ если он есть const clientId = actualContext.clientId || input.clientId const cleanClientId = clientId && clientId.startsWith('client_') ? clientId.substring(7) : clientId // Проверяем баланс для оплаты с баланса if (input.paymentMethod === 'balance') { console.log('createOrder: проверяем баланс для оплаты с баланса') // Сначала ищем дефолтный активный контракт, если нет - любой активный let contract = await prisma.clientContract.findFirst({ where: { clientId: cleanClientId, isActive: true, isDefault: true } }) if (!contract) { // Если дефолтного нет, ищем любой активный contract = await prisma.clientContract.findFirst({ where: { clientId: cleanClientId, isActive: true } }) } if (!contract) { throw new Error('Активный контракт не найден') } const availableBalance = (contract.balance || 0) + (contract.creditLimit || 0) console.log(`createOrder: доступный баланс: ${availableBalance}, сумма заказа: ${totalAmount}`) if (availableBalance < totalAmount) { throw new Error('Недостаточно средств на балансе для оплаты заказа') } } const order = await prisma.order.create({ data: { orderNumber, clientId: cleanClientId, clientEmail: input.clientEmail, clientPhone: input.clientPhone, clientName: input.clientName, totalAmount, finalAmount: totalAmount, // Пока без скидок deliveryAddress: input.deliveryAddress, comment: `${input.comment || ''}${input.paymentMethod ? ` | Способ оплаты: ${input.paymentMethod}` : ''}${input.legalEntityId ? ` | ЮЛ ID: ${input.legalEntityId}` : ''}`, items: { create: input.items.map(item => ({ productId: item.productId, externalId: item.externalId, name: item.name, article: item.article, brand: item.brand, price: item.price, quantity: item.quantity, totalPrice: item.price * item.quantity })) } }, include: { client: true, items: { include: { product: true } }, payments: true } }) // Если оплата с баланса, списываем средства и устанавливаем статус "Оплачен" if (input.paymentMethod === 'balance') { console.log('createOrder: списываем средства с баланса') // Ищем тот же контракт, который использовали для проверки баланса let contractToUpdate = await prisma.clientContract.findFirst({ where: { clientId: cleanClientId, isActive: true, isDefault: true } }) if (!contractToUpdate) { contractToUpdate = await prisma.clientContract.findFirst({ where: { clientId: cleanClientId, isActive: true } }) } if (contractToUpdate) { await prisma.clientContract.update({ where: { id: contractToUpdate.id }, data: { balance: { decrement: totalAmount } } }) console.log(`createOrder: списано ${totalAmount} ₽ с баланса контракта ${contractToUpdate.contractNumber}`) // Обновляем статус заказа на "Оплачен" await prisma.order.update({ where: { id: order.id }, data: { status: 'PAID' } }) console.log('createOrder: статус заказа изменен на PAID') } } console.log('createOrder: заказ создан:', order.orderNumber) return order } catch (error) { console.error('Ошибка создания заказа:', error) throw new Error('Не удалось создать заказ') } }, // Мутации для избранного addToFavorites: async (_: unknown, { input }: { input: FavoriteInput }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } // Удаляем префикс client_ если он есть const cleanClientId = actualContext.clientId.startsWith('client_') ? actualContext.clientId.substring(7) : actualContext.clientId // Проверяем, нет ли уже такого товара в избранном const existingFavorite = await prisma.favorite.findFirst({ where: { clientId: cleanClientId, productId: input.productId || undefined, offerKey: input.offerKey || undefined, article: input.article, brand: input.brand } }) if (existingFavorite) { return existingFavorite } const favorite = await prisma.favorite.create({ data: { clientId: cleanClientId, productId: input.productId, offerKey: input.offerKey, name: input.name, brand: input.brand, article: input.article, price: input.price, currency: input.currency, image: input.image }, include: { client: true } }) return favorite } catch (error) { console.error('Ошибка добавления в избранное:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось добавить товар в избранное') } }, removeFromFavorites: async (_: unknown, { id }: { id: string }, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } // Удаляем префикс client_ если он есть const cleanClientId = actualContext.clientId.startsWith('client_') ? actualContext.clientId.substring(7) : actualContext.clientId // Проверяем, что товар принадлежит текущему клиенту const existingFavorite = await prisma.favorite.findUnique({ where: { id } }) if (!existingFavorite || existingFavorite.clientId !== cleanClientId) { throw new Error('Товар не найден в избранном или недостаточно прав') } await prisma.favorite.delete({ where: { id } }) return true } catch (error) { console.error('Ошибка удаления из избранного:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось удалить товар из избранного') } }, clearFavorites: async (_: unknown, _args: unknown, context: Context) => { try { const actualContext = context || getContext() if (!actualContext.clientId) { throw new Error('Клиент не авторизован') } // Удаляем префикс client_ если он есть const cleanClientId = actualContext.clientId.startsWith('client_') ? actualContext.clientId.substring(7) : actualContext.clientId await prisma.favorite.deleteMany({ where: { clientId: cleanClientId } }) return true } catch (error) { console.error('Ошибка очистки избранного:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось очистить избранное') } }, // Resolver для подтверждения платежа confirmPayment: async (_: unknown, { orderId }: { orderId: string }, context: Context) => { try { console.log('confirmPayment: подтверждение платежа для заказа:', orderId) // Находим заказ const order = await prisma.order.findUnique({ where: { id: orderId }, include: { client: true, items: { include: { product: true } }, payments: true } }) if (!order) { throw new Error('Заказ не найден') } // Если заказ уже оплачен, просто возвращаем его if (order.status === 'PAID') { console.log('confirmPayment: заказ уже оплачен') return order } // Обновляем статус заказа на "Оплачен" const updatedOrder = await prisma.order.update({ where: { id: orderId }, data: { status: 'PAID' }, include: { client: true, items: { include: { product: true } }, payments: true } }) console.log('confirmPayment: статус заказа изменен на PAID') return updatedOrder } catch (error) { console.error('Ошибка подтверждения платежа:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось подтвердить платеж') } }, // Resolver для создания платежа createPayment: async (_: unknown, { input }: { input: CreatePaymentInput }, context: Context) => { try { console.log('createPayment: создание платежа для заказа:', input.orderId) // Находим заказ const order = await prisma.order.findUnique({ where: { id: input.orderId }, include: { items: true } }) if (!order) { throw new Error('Заказ не найден') } // Если заказ уже оплачен с баланса, не создаем платеж в ЮКассе if (order.status === 'PAID') { console.log('createPayment: заказ уже оплачен с баланса') // Возвращаем успешный результат без создания платежа в ЮКассе return { payment: null, confirmationUrl: null, success: true, message: 'Заказ уже оплачен с баланса' } } // Создаем платеж в ЮКассе const { yooKassaService } = await import('../yookassa-service') const payment = await yooKassaService.createPayment({ amount: order.finalAmount, currency: 'RUB', description: input.description || `Оплата заказа ${order.orderNumber}`, returnUrl: input.returnUrl, metadata: { orderId: order.id } }) console.log('createPayment: платеж создан в ЮКассе:', payment.id) // Маппинг статусов YooKassa на GraphQL enum const mapYooKassaStatus = (status: string) => { switch (status) { case 'pending': return 'PENDING' case 'waiting_for_capture': return 'WAITING_FOR_CAPTURE' case 'succeeded': return 'SUCCEEDED' case 'canceled': return 'CANCELED' default: return 'PENDING' } } return { payment: { id: payment.id, orderId: order.id, yookassaPaymentId: payment.id, status: mapYooKassaStatus(payment.status), amount: parseFloat(payment.amount.value), currency: payment.amount.currency, description: payment.description, confirmationUrl: payment.confirmation?.confirmation_url || null, createdAt: new Date().toISOString() }, confirmationUrl: payment.confirmation?.confirmation_url || null, success: true, message: 'Платеж успешно создан' } } catch (error) { console.error('Ошибка создания платежа:', error) if (error instanceof Error) { throw error } throw new Error('Не удалось создать платеж') } }, // Мутация для получения офферов доставки getDeliveryOffers: async (_: unknown, { input }: { input: { items: Array<{ name: string; article?: string; brand?: string; price: number; quantity: number; weight?: number; dimensions?: string; deliveryTime?: number; // Срок доставки товара к нам на склад offerKey?: string; // Для внешних товаров isExternal?: boolean; // Флаг внешнего товара }>; deliveryAddress: string; recipientName: string; recipientPhone: string; } }, context: Context) => { // Вычисляем максимальный срок доставки товаров к нам на склад (вне try блока для доступа в catch) const maxSupplierDeliveryDays = Math.max( ...input.items.map(item => item.deliveryTime || 0) ); try { console.log('🚚 Получение офферов доставки для:', input.deliveryAddress) console.log('📦 Максимальный срок поставки товаров на склад:', maxSupplierDeliveryDays, 'дней') console.log('📋 Товары в заказе:', input.items.map(item => ({ name: item.name, article: item.article, deliveryTime: item.deliveryTime, isExternal: item.isExternal }))) // Общие данные для Яндекс API const baseCartData = { items: input.items.map((item, index) => ({ id: `item_${index}`, name: item.name, article: item.article || '', price: item.price, quantity: item.quantity, weight: item.weight || 500, // 500г по умолчанию dimensions: item.dimensions ? { dx: 10, dy: 10, dz: 5 } : { dx: 10, dy: 10, dz: 5 }, // размеры по умолчанию deliveryTime: item.deliveryTime || 0, // Передаем срок поставки товара })), deliveryAddress: input.deliveryAddress, recipientName: input.recipientName, recipientPhone: input.recipientPhone, paymentMethod: 'already_paid' as const, // По умолчанию оплата уже произведена maxSupplierDeliveryDays: maxSupplierDeliveryDays, // Передаем максимальный срок поставки } const allOffers: any[] = [] // 1. Пробуем курьерскую доставку try { console.log('🚚 Пробуем курьерскую доставку...') const courierData = { ...baseCartData, deliveryType: 'courier' as const } const courierOffers = await yandexDeliveryService.createOfferFromCart(courierData) if (courierOffers.offers && courierOffers.offers.length > 0) { console.log(`✅ Найдено ${courierOffers.offers.length} офферов курьерской доставки`) allOffers.push(...courierOffers.offers.map(offer => ({ ...offer, delivery_type: 'courier' }))) } } catch (error) { console.log('⚠️ Курьерская доставка недоступна:', error instanceof Error ? error.message : 'Неизвестная ошибка') } // 2. Пробуем ПВЗ try { console.log('📦 Пробуем доставку в ПВЗ...') const pickupData = { ...baseCartData, deliveryType: 'pickup' as const } const pickupOffers = await yandexDeliveryService.createOfferFromCart(pickupData) if (pickupOffers.offers && pickupOffers.offers.length > 0) { console.log(`✅ Найдено ${pickupOffers.offers.length} офферов доставки в ПВЗ`) allOffers.push(...pickupOffers.offers.map(offer => ({ ...offer, delivery_type: 'pickup' }))) } } catch (error) { console.log('⚠️ Доставка в ПВЗ недоступна:', error instanceof Error ? error.message : 'Неизвестная ошибка') } console.log('✅ Всего получено офферов:', allOffers.length) // Удаляем дубликаты офферов с одинаковыми delivery_type const uniqueOffers = allOffers.reduce((acc, current) => { const existingOffer = acc.find(offer => offer.delivery_type === current.delivery_type) if (!existingOffer) { acc.push(current) } return acc }, [] as any[]) console.log(`🔄 Удалены дубликаты: ${allOffers.length} → ${uniqueOffers.length} офферов`) // Форматируем офферы для фронтенда const formattedOffers = uniqueOffers.map((offer, index) => { const deliveryInterval = offer.offer_details?.delivery_interval const pricing = offer.offer_details?.pricing const deliveryType = offer.delivery_type || 'courier' console.log('📅 Обработка оффера:', { offer_id: offer.offer_id, delivery_type: deliveryType, delivery_interval: deliveryInterval, pricing: pricing }) // Правильно вычисляем дату доставки с учетом срока поставки товара const today = new Date() const deliveryDate = new Date(today) deliveryDate.setDate(today.getDate() + maxSupplierDeliveryDays + 1) // +1 день на саму доставку let deliveryTime = '10:00-18:00' let deliveryCost = 0 if (deliveryInterval && typeof deliveryInterval === 'object' && 'min' in deliveryInterval) { // Проверяем, если это Unix timestamp let minDate: Date, maxDate: Date if (typeof deliveryInterval.min === 'number' && deliveryInterval.min > 1000000000) { // Это Unix timestamp в секундах minDate = new Date(deliveryInterval.min * 1000) maxDate = new Date(deliveryInterval.max * 1000) } else { // Это ISO строка или timestamp в миллисекундах minDate = new Date(deliveryInterval.min) maxDate = new Date(deliveryInterval.max) } // Проверяем, что даты валидны if (!isNaN(minDate.getTime()) && !isNaN(maxDate.getTime())) { // Используем минимальную дату из интервала + время поставки товара const calculatedDate = new Date(minDate) calculatedDate.setDate(minDate.getDate() + maxSupplierDeliveryDays) deliveryDate.setTime(calculatedDate.getTime()) if (deliveryType === 'pickup') { deliveryTime = `С ${deliveryDate.getDate()} ${deliveryDate.toLocaleDateString('ru-RU', { month: 'long' })}` } else { deliveryTime = `${minDate.getHours().toString().padStart(2, '0')}:${minDate.getMinutes().toString().padStart(2, '0')}-${maxDate.getHours().toString().padStart(2, '0')}:${maxDate.getMinutes().toString().padStart(2, '0')}` } } } if (pricing) { // Парсим стоимость из строки типа "192.15 RUB" const match = pricing.match(/(\d+(?:\.\d+)?)/); if (match) { deliveryCost = Math.round(parseFloat(match[1])) } } // Определяем название и описание в зависимости от типа доставки let name = 'Курьерская доставка' let description = 'Доставка курьером до двери' if (deliveryType === 'pickup') { name = 'Доставка в пункт выдачи (ПВЗ)' description = 'Получение в пункте выдачи заказов' deliveryCost = 0 // ПВЗ всегда бесплатно } if (maxSupplierDeliveryDays > 0) { if (deliveryType === 'pickup') { description = `Доставка включает ${maxSupplierDeliveryDays} дн. поставки товара + доставку в ПВЗ` } else { description = `Доставка включает ${maxSupplierDeliveryDays} дн. поставки товара + доставку до двери` } } const formattedDeliveryDate = deliveryDate.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'long' }) return { id: offer.offer_id || `offer_${deliveryType}_${index}`, name, deliveryDate: formattedDeliveryDate, deliveryTime, cost: deliveryCost, description, type: deliveryType, expiresAt: offer.expires_at ? new Date(offer.expires_at).toISOString() : null } }) // Проверяем есть ли оффер для ПВЗ среди полученных от Яндекса const hasPickupOffer = formattedOffers.some(offer => offer.type === 'pickup') const hasCourierOffer = formattedOffers.some(offer => offer.type === 'courier') // Добавляем стандартный ПВЗ оффер если его нет if (!hasPickupOffer) { console.log('📦 Добавляем стандартный ПВЗ оффер') const tomorrow = new Date() tomorrow.setDate(tomorrow.getDate() + 1 + maxSupplierDeliveryDays) const standardPickupOffer = { id: 'standard_pickup', name: 'Доставка в пункт выдачи (ПВЗ)', deliveryDate: tomorrow.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'long' }), deliveryTime: `С ${tomorrow.getDate()} ${tomorrow.toLocaleDateString('ru-RU', { month: 'long' })}`, cost: 0, // Самовывоз бесплатно description: maxSupplierDeliveryDays > 0 ? `Доставка включает ${maxSupplierDeliveryDays} дн. поставки товара + доставку в ПВЗ` : 'Получение в пункте выдачи заказов', type: 'pickup', expiresAt: null } formattedOffers.push(standardPickupOffer) } // Добавляем стандартный курьерский оффер если его нет if (!hasCourierOffer) { console.log('🚚 Добавляем стандартный курьерский оффер') const tomorrow = new Date() tomorrow.setDate(tomorrow.getDate() + 1 + maxSupplierDeliveryDays) const standardCourierOffer = { id: 'standard_courier', name: 'Курьерская доставка', deliveryDate: tomorrow.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'long' }), deliveryTime: '10:00-18:00', cost: 300, // Стандартная стоимость description: maxSupplierDeliveryDays > 0 ? `Доставка включает ${maxSupplierDeliveryDays} дн. поставки товара + доставку до двери` : 'Доставка курьером до двери', type: 'courier', expiresAt: null } formattedOffers.push(standardCourierOffer) } // Если совсем нет офферов, возвращаем полный набор стандартных if (formattedOffers.length === 0) { console.log('⚠️ Нет офферов от Яндекс Доставки, возвращаем полный стандартный набор') const tomorrow = new Date() tomorrow.setDate(tomorrow.getDate() + 1 + maxSupplierDeliveryDays) const standardOffers = [ { id: 'standard_courier', name: 'Курьерская доставка', deliveryDate: tomorrow.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'long' }), deliveryTime: '10:00-18:00', cost: 300, // Стандартная стоимость description: maxSupplierDeliveryDays > 0 ? `Доставка включает ${maxSupplierDeliveryDays} дн. поставки товара + доставку до двери` : 'Доставка курьером до двери', type: 'courier', expiresAt: null }, { id: 'standard_pickup', name: 'Доставка в пункт выдачи (ПВЗ)', deliveryDate: tomorrow.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'long' }), deliveryTime: `С ${tomorrow.getDate()} ${tomorrow.toLocaleDateString('ru-RU', { month: 'long' })}`, cost: 0, // Самовывоз бесплатно description: maxSupplierDeliveryDays > 0 ? `Доставка включает ${maxSupplierDeliveryDays} дн. поставки товара + доставку в ПВЗ` : 'Получение в пункте выдачи заказов', type: 'pickup', expiresAt: null } ] return { success: true, message: 'Получены стандартные варианты доставки', error: null, offers: standardOffers } } return { success: true, message: 'Офферы доставки успешно получены', error: null, offers: formattedOffers } } catch (error) { console.error('❌ Ошибка получения офферов доставки:', error) // В случае ошибки возвращаем стандартные варианты const tomorrow = new Date() tomorrow.setDate(tomorrow.getDate() + 1 + maxSupplierDeliveryDays) const fallbackOffers = [ { id: 'fallback_courier', name: 'Курьерская доставка', deliveryDate: tomorrow.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'long' }), deliveryTime: '10:00-18:00', cost: 300, description: maxSupplierDeliveryDays > 0 ? `Доставка включает ${maxSupplierDeliveryDays} дн. поставки товара + доставку до двери` : 'Доставка курьером до двери', type: 'courier', expiresAt: null }, { id: 'fallback_pickup', name: 'Доставка в пункт выдачи (ПВЗ)', deliveryDate: tomorrow.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'long' }), deliveryTime: `С ${tomorrow.getDate()} ${tomorrow.toLocaleDateString('ru-RU', { month: 'long' })}`, cost: 0, // Самовывоз бесплатно description: maxSupplierDeliveryDays > 0 ? `Доставка включает ${maxSupplierDeliveryDays} дн. поставки товара + доставку в ПВЗ` : 'Получение в пункте выдачи заказов', type: 'pickup', expiresAt: null } ] // Определяем сообщение в зависимости от типа ошибки let errorMessage = 'Временные проблемы с сервисом доставки' if (error instanceof Error) { if (error.message.includes('Missing some required address details')) { errorMessage = 'Требуется уточнение адреса доставки' } else if (error.message.includes('no_delivery_options')) { errorMessage = 'Доставка в данный адрес временно недоступна' } } return { success: true, // Меняем на true, так как мы предоставляем альтернативные варианты message: `${errorMessage}. Показаны стандартные варианты доставки.`, error: null, // Убираем детали ошибки API для пользователя offers: fallbackOffers } } } } }