diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index d299e01..ae2b53f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -59,6 +59,28 @@ model Category {
@@map("categories")
}
+model NavigationCategory {
+ id String @id @default(cuid())
+
+ // Привязка к категории PartsIndex
+ partsIndexCatalogId String // ID каталога из PartsIndex API
+ partsIndexGroupId String? // ID группы из PartsIndex API (необязательно)
+
+ // Только иконка - название берем из PartsIndex
+ icon String? // URL иконки в S3
+
+ // Настройки отображения
+ isHidden Boolean @default(false)
+ sortOrder Int @default(0)
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Уникальная комбинация catalogId + groupId
+ @@unique([partsIndexCatalogId, partsIndexGroupId])
+ @@map("navigation_categories")
+}
+
model Product {
id String @id @default(cuid())
name String
diff --git a/src/app/api/debug-partsindex/route.ts b/src/app/api/debug-partsindex/route.ts
new file mode 100644
index 0000000..367e7a1
--- /dev/null
+++ b/src/app/api/debug-partsindex/route.ts
@@ -0,0 +1,123 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { partsIndexService } from '@/lib/partsindex-service';
+
+export async function GET(request: NextRequest) {
+ try {
+ const url = new URL(request.url);
+ const action = url.searchParams.get('action');
+ const prefix = url.searchParams.get('prefix');
+
+ switch (action) {
+ case 'stats':
+ // Возвращаем статистику кэша
+ const stats = partsIndexService.getCacheStats();
+ return NextResponse.json({
+ success: true,
+ data: {
+ ...stats,
+ formattedEntries: stats.entries.map(entry => ({
+ ...entry,
+ sizeKB: Math.round(entry.size / 1024 * 100) / 100,
+ ageMinutes: Math.round(entry.age / (1000 * 60) * 100) / 100,
+ ttlMinutes: Math.round(entry.ttl / (1000 * 60) * 100) / 100,
+ isExpired: entry.age > entry.ttl
+ }))
+ }
+ });
+
+ case 'clear':
+ if (prefix) {
+ partsIndexService.clearCacheByPrefix(prefix);
+ return NextResponse.json({
+ success: true,
+ message: `Кэш с префиксом "${prefix}" очищен`
+ });
+ } else {
+ partsIndexService.clearCache();
+ return NextResponse.json({
+ success: true,
+ message: 'Весь кэш PartsIndex очищен'
+ });
+ }
+
+ case 'test':
+ // Тестовый запрос для проверки работы кэша
+ const catalogs = await partsIndexService.getCatalogs('ru');
+ return NextResponse.json({
+ success: true,
+ data: {
+ catalogsCount: catalogs.length,
+ catalogs: catalogs.slice(0, 3).map(c => ({ id: c.id, name: c.name }))
+ }
+ });
+
+ default:
+ return NextResponse.json({
+ success: false,
+ error: 'Неизвестное действие. Доступные: stats, clear, test'
+ }, { status: 400 });
+ }
+ } catch (error) {
+ console.error('❌ Ошибка в debug-partsindex API:', error);
+ return NextResponse.json({
+ success: false,
+ error: 'Внутренняя ошибка сервера'
+ }, { status: 500 });
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const { action, catalogId, lang = 'ru' } = body;
+
+ switch (action) {
+ case 'preload':
+ // Предзагрузка данных в кэш
+ console.log('🔄 Предзагрузка данных PartsIndex в кэш...');
+
+ const catalogs = await partsIndexService.getCatalogs(lang);
+ console.log(`✅ Загружено ${catalogs.length} каталогов`);
+
+ if (catalogId) {
+ // Загружаем группы конкретного каталога
+ const groups = await partsIndexService.getCatalogGroups(catalogId, lang);
+ console.log(`✅ Загружено ${groups.length} групп для каталога ${catalogId}`);
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ catalogsCount: catalogs.length,
+ groupsCount: groups.length,
+ catalogId
+ }
+ });
+ } else {
+ // Загружаем полную структуру
+ const categoriesWithGroups = await partsIndexService.getCategoriesWithGroups(lang);
+ const totalGroups = categoriesWithGroups.reduce((acc, cat) => acc + cat.groups.length, 0);
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ catalogsCount: catalogs.length,
+ categoriesWithGroupsCount: categoriesWithGroups.length,
+ totalGroups
+ }
+ });
+ }
+
+ default:
+ return NextResponse.json({
+ success: false,
+ error: 'Неизвестное действие. Доступные: preload'
+ }, { status: 400 });
+ }
+ } catch (error) {
+ console.error('❌ Ошибка в debug-partsindex POST API:', error);
+ return NextResponse.json({
+ success: false,
+ error: 'Внутренняя ошибка сервера'
+ }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/dashboard/navigation/page.tsx b/src/app/dashboard/navigation/page.tsx
new file mode 100644
index 0000000..7b8cf15
--- /dev/null
+++ b/src/app/dashboard/navigation/page.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import NavigationCategoryTree from '@/components/navigation/NavigationCategoryTree'
+
+export default function NavigationPage() {
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/catalog/CategoryForm.tsx b/src/components/catalog/CategoryForm.tsx
index 853d269..fbe1f17 100644
--- a/src/components/catalog/CategoryForm.tsx
+++ b/src/components/catalog/CategoryForm.tsx
@@ -302,6 +302,8 @@ export const CategoryForm = ({
+
+
{/* Настройки */}
Настройки
diff --git a/src/components/navigation/NavigationCategoryForm.tsx b/src/components/navigation/NavigationCategoryForm.tsx
new file mode 100644
index 0000000..012a93a
--- /dev/null
+++ b/src/components/navigation/NavigationCategoryForm.tsx
@@ -0,0 +1,381 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { Textarea } from '@/components/ui/textarea'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { useQuery } from '@apollo/client'
+import { GET_PARTSINDEX_CATEGORIES } from '@/lib/graphql/queries'
+import { Loader2, Upload, X, Image as ImageIcon, Folder, FolderOpen } from 'lucide-react'
+
+interface PartsIndexCategory {
+ id: string
+ name: string
+ image?: string
+ groups?: PartsIndexGroup[]
+}
+
+interface PartsIndexGroup {
+ id: string
+ name: string
+ image?: string
+ subgroups?: PartsIndexGroup[]
+}
+
+interface NavigationCategory {
+ id?: string
+ partsIndexCatalogId: string
+ partsIndexGroupId?: string
+ icon?: string
+ isHidden: boolean
+ sortOrder: number
+}
+
+interface NavigationCategoryFormProps {
+ category?: NavigationCategory | null
+ onSubmit: (data: any) => void
+ onCancel: () => void
+ isLoading?: boolean
+}
+
+export default function NavigationCategoryForm({
+ category,
+ onSubmit,
+ onCancel,
+ isLoading = false
+}: NavigationCategoryFormProps) {
+ const [formData, setFormData] = useState({
+ partsIndexCatalogId: '',
+ partsIndexGroupId: '',
+ icon: '',
+ isHidden: false,
+ sortOrder: 0
+ })
+
+ const [selectedCatalog, setSelectedCatalog] = useState
(null)
+ const [selectedGroup, setSelectedGroup] = useState(null)
+
+ // Загрузка категорий PartsIndex
+ const { data: categoriesData, loading: categoriesLoading, error: categoriesError } = useQuery(
+ GET_PARTSINDEX_CATEGORIES,
+ {
+ variables: { lang: 'ru' },
+ errorPolicy: 'all'
+ }
+ )
+
+ const categories = categoriesData?.partsIndexCategoriesWithGroups || []
+
+ // Заполнение формы при редактировании
+ useEffect(() => {
+ if (category) {
+ setFormData({
+ partsIndexCatalogId: category.partsIndexCatalogId || '',
+ partsIndexGroupId: category.partsIndexGroupId || '',
+ icon: category.icon || '',
+ isHidden: category.isHidden || false,
+ sortOrder: category.sortOrder || 0
+ })
+
+ // Находим выбранный каталог и группу
+ const catalog = categories.find(c => c.id === category.partsIndexCatalogId)
+ if (catalog) {
+ setSelectedCatalog(catalog)
+
+ if (category.partsIndexGroupId && catalog.groups) {
+ const group = findGroupById(catalog.groups, category.partsIndexGroupId)
+ setSelectedGroup(group || null)
+ }
+ }
+ }
+ }, [category, categories])
+
+ // Рекурсивный поиск группы по ID
+ const findGroupById = (groups: PartsIndexGroup[], groupId: string): PartsIndexGroup | null => {
+ for (const group of groups) {
+ if (group.id === groupId) {
+ return group
+ }
+ if (group.subgroups && group.subgroups.length > 0) {
+ const found = findGroupById(group.subgroups, groupId)
+ if (found) return found
+ }
+ }
+ return null
+ }
+
+ // Получение всех групп из каталога (включая подгруппы)
+ const getAllGroups = (groups: PartsIndexGroup[], level = 0): Array => {
+ const result: Array = []
+
+ groups.forEach(group => {
+ result.push({ ...group, level })
+ if (group.subgroups && group.subgroups.length > 0) {
+ result.push(...getAllGroups(group.subgroups, level + 1))
+ }
+ })
+
+ return result
+ }
+
+ const handleInputChange = (field: string, value: any) => {
+ setFormData(prev => ({ ...prev, [field]: value }))
+ }
+
+ const handleCatalogSelect = (catalogId: string) => {
+ const catalog = categories.find(c => c.id === catalogId)
+ setSelectedCatalog(catalog || null)
+ setSelectedGroup(null)
+
+ handleInputChange('partsIndexCatalogId', catalogId)
+ handleInputChange('partsIndexGroupId', '')
+ }
+
+ const handleGroupSelect = (groupId: string) => {
+ if (groupId === '__CATALOG_ROOT__') {
+ setSelectedGroup(null)
+ handleInputChange('partsIndexGroupId', '')
+ } else if (selectedCatalog?.groups) {
+ const group = findGroupById(selectedCatalog.groups, groupId)
+ setSelectedGroup(group || null)
+ handleInputChange('partsIndexGroupId', groupId)
+ }
+ }
+
+ const handleImageUpload = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (file) {
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ const result = e.target?.result as string
+ handleInputChange('icon', result)
+ }
+ reader.readAsDataURL(file)
+ }
+ }
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!formData.partsIndexCatalogId) {
+ alert('Выберите каталог')
+ return
+ }
+
+ onSubmit(formData)
+ }
+
+ const getDisplayName = () => {
+ if (selectedGroup) {
+ return `${selectedCatalog?.name} → ${selectedGroup.name}`
+ }
+ return selectedCatalog?.name || 'Выберите категорию'
+ }
+
+ if (categoriesLoading) {
+ return (
+
+
+
+ Загрузка категорий PartsIndex...
+
+
+ )
+ }
+
+ if (categoriesError) {
+ return (
+
+
+
+ Ошибка загрузки категорий PartsIndex: {categoriesError.message}
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {category ? 'Редактировать иконку категории' : 'Добавить иконку для категории'}
+
+
+ Выберите категорию из PartsIndex и загрузите иконку для отображения в навигации сайта
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/navigation/NavigationCategoryTree.tsx b/src/components/navigation/NavigationCategoryTree.tsx
new file mode 100644
index 0000000..6a09841
--- /dev/null
+++ b/src/components/navigation/NavigationCategoryTree.tsx
@@ -0,0 +1,377 @@
+'use client'
+
+import { useState } from 'react'
+import { useQuery, useMutation } from '@apollo/client'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import {
+ GET_NAVIGATION_CATEGORIES,
+ GET_PARTSINDEX_CATEGORIES
+} from '@/lib/graphql/queries'
+import {
+ CREATE_NAVIGATION_CATEGORY,
+ UPDATE_NAVIGATION_CATEGORY,
+ DELETE_NAVIGATION_CATEGORY
+} from '@/lib/graphql/mutations'
+import { Loader2, Plus, Edit, Trash2, Image as ImageIcon, Folder, Settings, Eye, EyeOff } from 'lucide-react'
+import NavigationCategoryForm from './NavigationCategoryForm'
+
+interface NavigationCategory {
+ id: string
+ partsIndexCatalogId: string
+ partsIndexGroupId?: string
+ icon?: string
+ isHidden: boolean
+ sortOrder: number
+ name: string
+ catalogName: string
+ groupName?: string
+}
+
+interface PartsIndexCategory {
+ id: string
+ name: string
+ image?: string
+ groups?: PartsIndexGroup[]
+}
+
+interface PartsIndexGroup {
+ id: string
+ name: string
+ image?: string
+ subgroups?: PartsIndexGroup[]
+}
+
+export default function NavigationCategoryTree() {
+ const [editingCategory, setEditingCategory] = useState(null)
+ const [showForm, setShowForm] = useState(false)
+
+ // Загрузка навигационных категорий (с иконками)
+ const {
+ data: navigationData,
+ loading: navigationLoading,
+ error: navigationError,
+ refetch: refetchNavigation
+ } = useQuery(GET_NAVIGATION_CATEGORIES, {
+ errorPolicy: 'all'
+ })
+
+ // Загрузка категорий PartsIndex
+ const {
+ data: partsIndexData,
+ loading: partsIndexLoading,
+ error: partsIndexError
+ } = useQuery(GET_PARTSINDEX_CATEGORIES, {
+ variables: { lang: 'ru' },
+ errorPolicy: 'all'
+ })
+
+ // Мутации
+ const [createCategory, { loading: creating }] = useMutation(CREATE_NAVIGATION_CATEGORY, {
+ onCompleted: () => {
+ refetchNavigation()
+ handleCloseForm()
+ },
+ onError: (error) => {
+ console.error('Ошибка создания категории:', error)
+ alert('Не удалось создать иконку для категории')
+ }
+ })
+
+ const [updateCategory, { loading: updating }] = useMutation(UPDATE_NAVIGATION_CATEGORY, {
+ onCompleted: () => {
+ refetchNavigation()
+ handleCloseForm()
+ },
+ onError: (error) => {
+ console.error('Ошибка обновления категории:', error)
+ alert('Не удалось обновить иконку категории')
+ }
+ })
+
+ const [deleteCategory, { loading: deleting }] = useMutation(DELETE_NAVIGATION_CATEGORY, {
+ onCompleted: () => {
+ refetchNavigation()
+ },
+ onError: (error) => {
+ console.error('Ошибка удаления категории:', error)
+ alert('Не удалось удалить иконку категории')
+ }
+ })
+
+ const navigationCategories = navigationData?.navigationCategories || []
+ const partsIndexCategories = partsIndexData?.partsIndexCategoriesWithGroups || []
+
+ const handleSubmit = async (formData: any) => {
+ try {
+ if (editingCategory) {
+ await updateCategory({
+ variables: {
+ id: editingCategory.id,
+ input: formData
+ }
+ })
+ } else {
+ await createCategory({
+ variables: {
+ input: formData
+ }
+ })
+ }
+ } catch (error) {
+ console.error('Ошибка сохранения:', error)
+ }
+ }
+
+ const handleEdit = (category: NavigationCategory) => {
+ setEditingCategory(category)
+ setShowForm(true)
+ }
+
+ const handleDelete = async (category: NavigationCategory) => {
+ if (confirm(`Удалить иконку для категории "${category.name}"?`)) {
+ await deleteCategory({
+ variables: { id: category.id }
+ })
+ }
+ }
+
+ const handleCloseForm = () => {
+ setEditingCategory(null)
+ setShowForm(false)
+ }
+
+ // Функция для получения полного пути категории
+ const getCategoryPath = (catalogId: string, groupId?: string) => {
+ const catalog = partsIndexCategories.find(c => c.id === catalogId)
+ if (!catalog) return 'Неизвестная категория'
+
+ if (!groupId) return catalog.name
+
+ // Рекурсивный поиск группы
+ const findGroup = (groups: PartsIndexGroup[]): PartsIndexGroup | null => {
+ for (const group of groups) {
+ if (group.id === groupId) return group
+ if (group.subgroups) {
+ const found = findGroup(group.subgroups)
+ if (found) return found
+ }
+ }
+ return null
+ }
+
+ const group = catalog.groups ? findGroup(catalog.groups) : null
+ return group ? `${catalog.name} → ${group.name}` : catalog.name
+ }
+
+ // Проверка есть ли иконка для категории
+ const hasIcon = (catalogId: string, groupId?: string) => {
+ return navigationCategories.some(nav =>
+ nav.partsIndexCatalogId === catalogId &&
+ nav.partsIndexGroupId === groupId
+ )
+ }
+
+ // Получение иконки для категории
+ const getIcon = (catalogId: string, groupId?: string) => {
+ return navigationCategories.find(nav =>
+ nav.partsIndexCatalogId === catalogId &&
+ nav.partsIndexGroupId === groupId
+ )
+ }
+
+ if (showForm) {
+ return (
+
+ )
+ }
+
+ if (navigationLoading || partsIndexLoading) {
+ return (
+
+
+
+ Загрузка категорий...
+
+
+ )
+ }
+
+ if (navigationError || partsIndexError) {
+ return (
+
+
+ Ошибка загрузки категорий: {navigationError?.message || partsIndexError?.message}
+
+
+ )
+ }
+
+ return (
+
+ {/* Заголовок */}
+
+
+
Иконки навигации
+
+ Привязка иконок к категориям PartsIndex для отображения в навигации сайта
+
+
+
setShowForm(true)}>
+
+ Добавить иконку
+
+
+
+ {/* Статистика */}
+
+
+
+
+
+
+
Каталогов PartsIndex
+
{partsIndexCategories.length}
+
+
+
+
+
+
+
+
+
+
+
С иконками
+
{navigationCategories.length}
+
+
+
+
+
+
+
+
+
+
+
Активных
+
+ {navigationCategories.filter(cat => !cat.isHidden).length}
+
+
+
+
+
+
+
+ {/* Список категорий с иконками */}
+ {navigationCategories.length > 0 && (
+
+
+ Категории с иконками
+
+
+
+ {navigationCategories
+ .sort((a, b) => a.sortOrder - b.sortOrder)
+ .map((navCategory) => (
+
+
+ {/* Иконка */}
+
+ {navCategory.icon ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Информация */}
+
+
{navCategory.name}
+
+ {getCategoryPath(navCategory.partsIndexCatalogId, navCategory.partsIndexGroupId)}
+
+
+
+ Сортировка: {navCategory.sortOrder}
+
+ {navCategory.isHidden && (
+
+
+ Скрыта
+
+ )}
+ {!navCategory.isHidden && (
+
+
+ Видима
+
+ )}
+
+
+
+
+ {/* Действия */}
+
+ handleEdit(navCategory)}
+ disabled={deleting}
+ >
+
+
+ handleDelete(navCategory)}
+ disabled={deleting}
+ >
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {/* Инструкции */}
+
+
+ Как использовать
+
+
+
+ 1. Выберите каталог: Из списка каталогов PartsIndex выберите тот, для которого хотите добавить иконку.
+
+
+ 2. Выберите группу (необязательно): Если хотите добавить иконку для конкретной группы внутри каталога, выберите её. Иначе иконка будет применена ко всему каталогу.
+
+
+ 3. Загрузите иконку: Выберите небольшое изображение (рекомендуется 32x32 пикселя) которое будет отображаться в навигации сайта.
+
+
+ 4. Настройте отображение: Установите порядок сортировки и видимость категории в навигации.
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx
index 34c9725..8cfc530 100644
--- a/src/components/ui/sidebar.tsx
+++ b/src/components/ui/sidebar.tsx
@@ -34,6 +34,11 @@ const navigationItems = [
href: '/dashboard/catalog',
icon: Package,
},
+ {
+ title: 'Навигация сайта',
+ href: '/dashboard/navigation',
+ icon: Star,
+ },
{
title: 'Товары главной',
href: '/dashboard/homepage-products',
diff --git a/src/lib/graphql/mutations.ts b/src/lib/graphql/mutations.ts
index 01f637d..41d5cee 100644
--- a/src/lib/graphql/mutations.ts
+++ b/src/lib/graphql/mutations.ts
@@ -137,6 +137,7 @@ export const CREATE_CATEGORY = gql`
seoTitle
seoDescription
image
+ icon
isHidden
includeSubcategoryProducts
parentId
@@ -160,6 +161,7 @@ export const UPDATE_CATEGORY = gql`
seoTitle
seoDescription
image
+ icon
isHidden
includeSubcategoryProducts
parentId
@@ -179,6 +181,49 @@ export const DELETE_CATEGORY = gql`
}
`
+// Навигационные категории
+export const CREATE_NAVIGATION_CATEGORY = gql`
+ mutation CreateNavigationCategory($input: NavigationCategoryInput!) {
+ createNavigationCategory(input: $input) {
+ id
+ partsIndexCatalogId
+ partsIndexGroupId
+ icon
+ isHidden
+ sortOrder
+ createdAt
+ updatedAt
+ name
+ catalogName
+ groupName
+ }
+ }
+`
+
+export const UPDATE_NAVIGATION_CATEGORY = gql`
+ mutation UpdateNavigationCategory($id: ID!, $input: NavigationCategoryInput!) {
+ updateNavigationCategory(id: $id, input: $input) {
+ id
+ partsIndexCatalogId
+ partsIndexGroupId
+ icon
+ isHidden
+ sortOrder
+ createdAt
+ updatedAt
+ name
+ catalogName
+ groupName
+ }
+ }
+`
+
+export const DELETE_NAVIGATION_CATEGORY = gql`
+ mutation DeleteNavigationCategory($id: ID!) {
+ deleteNavigationCategory(id: $id)
+ }
+`
+
export const DELETE_PRODUCTS = gql`
mutation DeleteProducts($ids: [ID!]!) {
deleteProducts(ids: $ids) {
diff --git a/src/lib/graphql/queries.ts b/src/lib/graphql/queries.ts
index 84cb795..0e8a74c 100644
--- a/src/lib/graphql/queries.ts
+++ b/src/lib/graphql/queries.ts
@@ -1401,4 +1401,70 @@ export const GET_PAYMENT = gql`
}
`
+// Навигационные категории
+export const GET_NAVIGATION_CATEGORIES = gql`
+ query GetNavigationCategories {
+ navigationCategories {
+ id
+ partsIndexCatalogId
+ partsIndexGroupId
+ icon
+ isHidden
+ sortOrder
+ createdAt
+ updatedAt
+ name
+ catalogName
+ groupName
+ }
+ }
+`;
+
+export const GET_NAVIGATION_CATEGORY = gql`
+ query GetNavigationCategory($id: ID!) {
+ navigationCategory(id: $id) {
+ id
+ partsIndexCatalogId
+ partsIndexGroupId
+ icon
+ isHidden
+ sortOrder
+ createdAt
+ updatedAt
+ name
+ catalogName
+ groupName
+ }
+ }
+`;
+
+// PartsIndex категории автотоваров
+export const GET_PARTSINDEX_CATEGORIES = gql`
+ query GetPartsIndexCategories($lang: String) {
+ partsIndexCategoriesWithGroups(lang: $lang) {
+ id
+ name
+ image
+ groups {
+ id
+ name
+ image
+ subgroups {
+ id
+ name
+ image
+ entityNames {
+ id
+ name
+ }
+ }
+ entityNames {
+ id
+ name
+ }
+ }
+ }
+ }
+`;
+
\ No newline at end of file
diff --git a/src/lib/graphql/resolvers.ts b/src/lib/graphql/resolvers.ts
index 7ef2fc2..aeb01d6 100644
--- a/src/lib/graphql/resolvers.ts
+++ b/src/lib/graphql/resolvers.ts
@@ -132,6 +132,15 @@ interface CategoryInput {
parentId?: string
}
+// Интерфейсы для навигационных категорий
+interface NavigationCategoryInput {
+ partsIndexCatalogId: string
+ partsIndexGroupId?: string
+ icon?: string
+ isHidden?: boolean
+ sortOrder?: number
+}
+
interface ProductInput {
name: string
slug?: string
@@ -1870,6 +1879,90 @@ export const resolvers = {
}
},
+ // Навигационные категории
+ navigationCategories: async () => {
+ try {
+ const categories = await prisma.navigationCategory.findMany({
+ where: { isHidden: false },
+ orderBy: { sortOrder: 'asc' }
+ })
+
+ // Получаем данные из PartsIndex для каждой категории
+ const categoriesWithData = await Promise.all(
+ categories.map(async (category) => {
+ try {
+ // Получаем каталоги PartsIndex
+ const catalogs = await partsIndexService.getCatalogs('ru')
+ const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId)
+
+ let groupName = null
+
+ // Если есть groupId, получаем группы
+ if (category.partsIndexGroupId && catalog) {
+ const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru')
+ const group = groups.find(g => g.id === category.partsIndexGroupId)
+ groupName = group?.name || null
+ }
+
+ return {
+ ...category,
+ name: groupName || catalog?.name || 'Неизвестная категория',
+ catalogName: catalog?.name || 'Неизвестный каталог',
+ groupName
+ }
+ } catch (error) {
+ console.error('Ошибка получения данных PartsIndex для категории:', category.id, error)
+ return {
+ ...category,
+ name: 'Ошибка загрузки',
+ catalogName: 'Ошибка загрузки',
+ groupName: null
+ }
+ }
+ })
+ )
+
+ return categoriesWithData
+ } catch (error) {
+ console.error('Ошибка получения навигационных категорий:', error)
+ return []
+ }
+ },
+
+ navigationCategory: async (_: unknown, { id }: { id: string }) => {
+ try {
+ const category = await prisma.navigationCategory.findUnique({
+ where: { id }
+ })
+
+ if (!category) {
+ throw new Error('Навигационная категория не найдена')
+ }
+
+ // Получаем данные из PartsIndex
+ const catalogs = await partsIndexService.getCatalogs('ru')
+ const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId)
+
+ let groupName = null
+
+ if (category.partsIndexGroupId && catalog) {
+ const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru')
+ const group = groups.find(g => g.id === category.partsIndexGroupId)
+ groupName = group?.name || null
+ }
+
+ return {
+ ...category,
+ name: groupName || catalog?.name || 'Неизвестная категория',
+ catalogName: catalog?.name || 'Неизвестный каталог',
+ groupName
+ }
+ } catch (error) {
+ console.error('Ошибка получения навигационной категории:', error)
+ throw error
+ }
+ },
+
laximoUnits: async (_: unknown, { catalogCode, vehicleId, ssd, categoryId }: { catalogCode: string; vehicleId?: string; ssd?: string; categoryId?: string }) => {
try {
console.log('🔍 GraphQL Resolver - запрос узлов каталога:', {
@@ -3080,6 +3173,67 @@ export const resolvers = {
}
},
+ // Получить параметры каталога PartsIndex для фильтрации
+ partsIndexCatalogParams: async (_: unknown, {
+ catalogId,
+ groupId,
+ lang = 'ru',
+ engineId,
+ generationId,
+ params,
+ q
+ }: {
+ catalogId: string;
+ groupId: string;
+ lang?: 'ru' | 'en';
+ engineId?: string;
+ generationId?: string;
+ params?: string;
+ q?: string;
+ }) => {
+ try {
+ console.log('🔍 GraphQL resolver partsIndexCatalogParams вызван с параметрами:', {
+ catalogId,
+ groupId,
+ lang,
+ q
+ })
+
+ // Преобразуем строку params в объект если передан
+ let parsedParams: Record | undefined;
+ if (params) {
+ try {
+ parsedParams = JSON.parse(params);
+ } catch (error) {
+ console.warn('⚠️ Не удалось разобрать параметры фильтрации:', params);
+ }
+ }
+
+ const paramsData = await partsIndexService.getCatalogParams(catalogId, groupId, {
+ lang,
+ engineId,
+ generationId,
+ params: parsedParams,
+ q
+ })
+
+ if (!paramsData) {
+ console.warn('⚠️ Не удалось получить параметры каталога')
+ return {
+ list: [],
+ paramsQuery: {}
+ }
+ }
+
+ console.log('✅ Получены параметры каталога:', paramsData.list.length)
+
+ return paramsData
+ } catch (error) {
+ console.error('❌ Ошибка в GraphQL resolver partsIndexCatalogParams:', error)
+ throw new Error('Не удалось получить параметры каталога')
+ }
+ },
+
// PartsAPI артикулы
partsAPIArticles: async (_: unknown, { strId, carId, carType = 'PC' }: { strId: number; carId: number; carType?: 'PC' | 'CV' | 'Motorcycle' }) => {
try {
@@ -4442,6 +4596,223 @@ export const resolvers = {
}
},
+ // Навигационные категории
+ createNavigationCategory: async (_: unknown, { input }: { input: NavigationCategoryInput }, context: Context) => {
+ try {
+ if (!context.userId) {
+ throw new Error('Пользователь не авторизован')
+ }
+
+ // Проверяем что такой комбинации еще нет
+ const existing = await prisma.navigationCategory.findFirst({
+ where: {
+ partsIndexCatalogId: input.partsIndexCatalogId,
+ partsIndexGroupId: input.partsIndexGroupId ?? null
+ }
+ })
+
+ if (existing) {
+ throw new Error('Иконка для этой категории уже существует')
+ }
+
+ // Загружаем иконку в S3 если есть
+ let iconUrl: string | null = null
+ if (input.icon) {
+ try {
+ const iconData = input.icon.replace(/^data:image\/[a-z]+;base64,/, '')
+ const buffer = Buffer.from(iconData, 'base64')
+
+ const fileKey = generateFileKey('navigation-icons', 'png')
+ const uploadResult = await uploadBuffer(buffer, fileKey, 'image/png')
+ iconUrl = uploadResult.url
+ } catch (error) {
+ console.error('Ошибка загрузки иконки:', error)
+ throw new Error('Не удалось загрузить иконку')
+ }
+ }
+
+ const category = await prisma.navigationCategory.create({
+ data: {
+ partsIndexCatalogId: input.partsIndexCatalogId,
+ partsIndexGroupId: input.partsIndexGroupId ?? null,
+ icon: iconUrl,
+ isHidden: input.isHidden || false,
+ sortOrder: input.sortOrder || 0
+ }
+ })
+
+ // Получаем данные из PartsIndex для ответа
+ const catalogs = await partsIndexService.getCatalogs('ru')
+ const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId)
+
+ let groupName = null
+ if (category.partsIndexGroupId && catalog) {
+ const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru')
+ const group = groups.find(g => g.id === category.partsIndexGroupId)
+ groupName = group?.name || null
+ }
+
+ const result = {
+ ...category,
+ name: groupName || catalog?.name || 'Неизвестная категория',
+ catalogName: catalog?.name || 'Неизвестный каталог',
+ groupName
+ }
+
+ // Логируем действие
+ if (context.headers) {
+ const { ipAddress, userAgent } = getClientInfo(context.headers)
+ await createAuditLog({
+ userId: context.userId,
+ action: AuditAction.CATEGORY_CREATE,
+ details: `Навигационная категория: ${result.name}`,
+ ipAddress,
+ userAgent
+ })
+ }
+
+ return result
+ } catch (error) {
+ console.error('Ошибка создания навигационной категории:', error)
+ throw error
+ }
+ },
+
+ updateNavigationCategory: async (_: unknown, { id, input }: { id: string; input: NavigationCategoryInput }, context: Context) => {
+ try {
+ if (!context.userId) {
+ throw new Error('Пользователь не авторизован')
+ }
+
+ const existingCategory = await prisma.navigationCategory.findUnique({
+ where: { id }
+ })
+
+ if (!existingCategory) {
+ throw new Error('Навигационная категория не найдена')
+ }
+
+ // Проверяем уникальность если изменяются partsIndex поля
+ if (input.partsIndexCatalogId || input.partsIndexGroupId !== undefined) {
+ const catalogId = input.partsIndexCatalogId || existingCategory.partsIndexCatalogId
+ const groupId = input.partsIndexGroupId !== undefined ? input.partsIndexGroupId : existingCategory.partsIndexGroupId
+
+ const conflicting = await prisma.navigationCategory.findFirst({
+ where: {
+ partsIndexCatalogId: catalogId,
+ partsIndexGroupId: groupId
+ }
+ })
+
+ if (conflicting && conflicting.id !== id) {
+ throw new Error('Иконка для этой категории уже существует')
+ }
+ }
+
+ // Загружаем новую иконку если есть
+ let iconUrl = existingCategory.icon
+ if (input.icon) {
+ try {
+ const iconData = input.icon.replace(/^data:image\/[a-z]+;base64,/, '')
+ const buffer = Buffer.from(iconData, 'base64')
+
+ const fileKey = generateFileKey('navigation-icons', 'png')
+ const uploadResult = await uploadBuffer(buffer, fileKey, 'image/png')
+ iconUrl = uploadResult.url
+ } catch (error) {
+ console.error('Ошибка загрузки иконки:', error)
+ throw new Error('Не удалось загрузить иконку')
+ }
+ }
+
+ const category = await prisma.navigationCategory.update({
+ where: { id },
+ data: {
+ partsIndexCatalogId: input.partsIndexCatalogId || existingCategory.partsIndexCatalogId,
+ partsIndexGroupId: input.partsIndexGroupId !== undefined ? (input.partsIndexGroupId ?? null) : existingCategory.partsIndexGroupId,
+ icon: iconUrl,
+ isHidden: input.isHidden !== undefined ? input.isHidden : existingCategory.isHidden,
+ sortOrder: input.sortOrder !== undefined ? input.sortOrder : existingCategory.sortOrder
+ }
+ })
+
+ // Получаем данные из PartsIndex для ответа
+ const catalogs = await partsIndexService.getCatalogs('ru')
+ const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId)
+
+ let groupName = null
+ if (category.partsIndexGroupId && catalog) {
+ const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru')
+ const group = groups.find(g => g.id === category.partsIndexGroupId)
+ groupName = group?.name || null
+ }
+
+ const result = {
+ ...category,
+ name: groupName || catalog?.name || 'Неизвестная категория',
+ catalogName: catalog?.name || 'Неизвестный каталог',
+ groupName
+ }
+
+ // Логируем действие
+ if (context.headers) {
+ const { ipAddress, userAgent } = getClientInfo(context.headers)
+ await createAuditLog({
+ userId: context.userId,
+ action: AuditAction.CATEGORY_UPDATE,
+ details: `Навигационная категория: ${result.name}`,
+ ipAddress,
+ userAgent
+ })
+ }
+
+ return result
+ } catch (error) {
+ console.error('Ошибка обновления навигационной категории:', error)
+ throw error
+ }
+ },
+
+ deleteNavigationCategory: async (_: unknown, { id }: { id: string }, context: Context) => {
+ try {
+ if (!context.userId) {
+ throw new Error('Пользователь не авторизован')
+ }
+
+ const category = await prisma.navigationCategory.findUnique({
+ where: { id }
+ })
+
+ if (!category) {
+ throw new Error('Навигационная категория не найдена')
+ }
+
+ await prisma.navigationCategory.delete({
+ where: { id }
+ })
+
+ // Логируем действие
+ if (context.headers) {
+ const { ipAddress, userAgent } = getClientInfo(context.headers)
+ await createAuditLog({
+ userId: context.userId,
+ action: AuditAction.CATEGORY_DELETE,
+ details: `Навигационная категория ID: ${category.id}`,
+ ipAddress,
+ userAgent
+ })
+ }
+
+ return true
+ } catch (error) {
+ console.error('Ошибка удаления навигационной категории:', error)
+ if (error instanceof Error) {
+ throw error
+ }
+ throw new Error('Не удалось удалить навигационную категорию')
+ }
+ },
+
// Товары
createProduct: async (_: unknown, {
input,
diff --git a/src/lib/graphql/typeDefs.ts b/src/lib/graphql/typeDefs.ts
index 35c7e91..ab1bcbd 100644
--- a/src/lib/graphql/typeDefs.ts
+++ b/src/lib/graphql/typeDefs.ts
@@ -73,6 +73,22 @@ export const typeDefs = gql`
updatedAt: DateTime!
}
+ type NavigationCategory {
+ id: ID!
+ partsIndexCatalogId: String!
+ partsIndexGroupId: String
+ icon: String
+ isHidden: Boolean!
+ sortOrder: Int!
+ createdAt: DateTime!
+ updatedAt: DateTime!
+
+ # Виртуальные поля - получаем данные из PartsIndex API
+ name: String!
+ catalogName: String!
+ groupName: String
+ }
+
type Product {
id: ID!
name: String!
@@ -600,6 +616,14 @@ export const typeDefs = gql`
parentId: String
}
+ input NavigationCategoryInput {
+ partsIndexCatalogId: String!
+ partsIndexGroupId: String
+ icon: String
+ isHidden: Boolean
+ sortOrder: Int
+ }
+
input ProductInput {
name: String!
slug: String
@@ -924,6 +948,10 @@ export const typeDefs = gql`
laximoQuickGroups(catalogCode: String!, vehicleId: String, ssd: String): [LaximoQuickGroup!]!
laximoQuickGroupsWithXML(catalogCode: String!, vehicleId: String, ssd: String): LaximoQuickGroupsResponse!
laximoCategories(catalogCode: String!, vehicleId: String, ssd: String): [LaximoQuickGroup!]!
+
+ # Навигационные категории
+ navigationCategories: [NavigationCategory!]!
+ navigationCategory(id: ID!): NavigationCategory
laximoUnits(catalogCode: String!, vehicleId: String, ssd: String, categoryId: String): [LaximoQuickGroup!]!
laximoQuickDetail(catalogCode: String!, vehicleId: String!, quickGroupId: String!, ssd: String!): LaximoQuickDetail
laximoOEMSearch(catalogCode: String!, vehicleId: String!, oemNumber: String!, ssd: String!): LaximoOEMResult
@@ -1088,6 +1116,11 @@ export const typeDefs = gql`
updateCategory(id: ID!, input: CategoryInput!): Category!
deleteCategory(id: ID!): Boolean!
+ # Навигационные категории
+ createNavigationCategory(input: NavigationCategoryInput!): NavigationCategory!
+ updateNavigationCategory(id: ID!, input: NavigationCategoryInput!): NavigationCategory!
+ deleteNavigationCategory(id: ID!): Boolean!
+
# Товары
createProduct(input: ProductInput!, images: [ProductImageInput!], characteristics: [CharacteristicInput!], options: [ProductOptionInput!]): Product!
updateProduct(id: ID!, input: ProductInput!, images: [ProductImageInput!], characteristics: [CharacteristicInput!], options: [ProductOptionInput!]): Product!
diff --git a/src/lib/partsindex-service.ts b/src/lib/partsindex-service.ts
index 502f905..63168f6 100644
--- a/src/lib/partsindex-service.ts
+++ b/src/lib/partsindex-service.ts
@@ -1,9 +1,10 @@
import axios from 'axios';
+// Интерфейсы для типизации данных Parts Index API
export interface PartsIndexCatalog {
id: string;
name: string;
- image: string | null;
+ image: string;
}
export interface PartsIndexGroup {
@@ -14,10 +15,6 @@ export interface PartsIndexGroup {
entityNames: { id: string; name: string; }[];
}
-export interface PartsIndexCatalogsResponse {
- list: PartsIndexCatalog[];
-}
-
export interface PartsIndexGroupResponse {
id: string;
name: string;
@@ -29,35 +26,30 @@ export interface PartsIndexGroupResponse {
subgroups: PartsIndexGroup[];
}
-// Новые интерфейсы для товаров каталога
-export interface PartsIndexParameter {
- id: string;
- code: string;
- title: string;
- type: string;
- values: Array<{
- id: string;
- value: string;
- }>;
-}
-
-export interface PartsIndexBrand {
- id: string;
- name: string;
-}
-
-export interface PartsIndexProductName {
- id: string;
- name: string;
-}
-
export interface PartsIndexEntity {
id: string;
- name: PartsIndexProductName;
- originalName: string;
code: string;
- brand: PartsIndexBrand;
- parameters: PartsIndexParameter[];
+ name: {
+ id: string;
+ name: string;
+ };
+ originalName: string;
+ brand: {
+ id: string;
+ name: string;
+ };
+ barcodes: string[];
+ parameters: {
+ id: string;
+ title: string;
+ code: string;
+ type: string;
+ values: {
+ id: string;
+ value: string;
+ title?: string;
+ }[];
+ }[];
images: string[];
}
@@ -74,6 +66,8 @@ export interface PartsIndexEntitiesResponse {
catalog: {
id: string;
name: string;
+ image: string;
+ groups: PartsIndexGroup[];
};
subgroup: {
id: string;
@@ -82,24 +76,22 @@ export interface PartsIndexEntitiesResponse {
}
export interface PartsIndexParamsResponse {
- list: PartsIndexParam[];
- paramsQuery: Record;
+ list: {
+ id: string;
+ name: string;
+ code: string;
+ type: 'range' | 'dropdown';
+ values: {
+ id: string;
+ value: string;
+ title?: string;
+ available: boolean;
+ }[];
+ }[];
}
-export interface PartsIndexParam {
- id: string;
- code: string;
- name: string;
- isGeneral: boolean;
- type: 'select' | 'range';
- values: PartsIndexParamValue[];
-}
-
-export interface PartsIndexParamValue {
- value: string;
- title: string;
- available: boolean;
- selected: boolean;
+export interface PartsIndexEntityInfoResponse {
+ list: PartsIndexEntityDetail[];
}
export interface PartsIndexEntityDetail {
@@ -155,20 +147,99 @@ export interface PartsIndexEntityDetail {
}[];
}
+// Интерфейс для кэша
+interface CacheEntry {
+ data: T;
+ timestamp: number;
+ ttl: number; // время жизни в миллисекундах
+}
+
class PartsIndexService {
private baseURL = 'https://api.parts-index.com/v1';
- private apiKey: string;
+ private apiKey = 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE';
+
+ // Простой in-memory кэш
+ private cache = new Map>();
+ private readonly DEFAULT_TTL = 30 * 60 * 1000; // 30 минут
+ private readonly CATALOGS_TTL = 24 * 60 * 60 * 1000; // 24 часа для каталогов
+ private readonly GROUPS_TTL = 24 * 60 * 60 * 1000; // 24 часа для групп
+ private readonly ENTITIES_TTL = 10 * 60 * 1000; // 10 минут для товаров
+ private readonly PARAMS_TTL = 60 * 60 * 1000; // 1 час для параметров
- constructor() {
- this.apiKey = process.env.PARTSINDEX_API_KEY || '';
-
- if (!this.apiKey) {
- console.error('❌ PartsIndex API ключ не найден в переменных окружения');
+ // Проверяем актуальность кэша
+ private isValidCacheEntry(entry: CacheEntry): boolean {
+ return Date.now() - entry.timestamp < entry.ttl;
+ }
+
+ // Получаем данные из кэша
+ private getFromCache(key: string): T | null {
+ const entry = this.cache.get(key);
+ if (entry && this.isValidCacheEntry(entry)) {
+ console.log(`🔥 Используем кэш для ключа: ${key}`);
+ return entry.data;
}
+ if (entry) {
+ console.log(`🗑️ Удаляем устаревший кэш для ключа: ${key}`);
+ this.cache.delete(key);
+ }
+ return null;
+ }
+
+ // Сохраняем данные в кэш
+ private setCache(key: string, data: T, ttl: number = this.DEFAULT_TTL): void {
+ this.cache.set(key, {
+ data,
+ timestamp: Date.now(),
+ ttl
+ });
+ console.log(`💾 Сохранено в кэш: ${key} (TTL: ${ttl}ms)`);
+ }
+
+ // Очистка кэша (для административных целей)
+ public clearCache(): void {
+ this.cache.clear();
+ console.log('🗑️ Кэш PartsIndex полностью очищен');
+ }
+
+ // Очистка конкретного типа кэша
+ public clearCacheByPrefix(prefix: string): void {
+ const keysToDelete: string[] = [];
+ this.cache.forEach((_, key) => {
+ if (key.startsWith(prefix)) {
+ keysToDelete.push(key);
+ }
+ });
+
+ keysToDelete.forEach(key => this.cache.delete(key));
+ console.log(`🗑️ Очищен кэш PartsIndex с префиксом: ${prefix} (${keysToDelete.length} записей)`);
+ }
+
+ // Статистика кэша
+ public getCacheStats(): { size: number; entries: { key: string; size: number; ttl: number; age: number }[] } {
+ const entries: { key: string; size: number; ttl: number; age: number }[] = [];
+
+ this.cache.forEach((entry, key) => {
+ const size = JSON.stringify(entry.data).length;
+ const age = Date.now() - entry.timestamp;
+ entries.push({ key, size, ttl: entry.ttl, age });
+ });
+
+ return {
+ size: this.cache.size,
+ entries: entries.sort((a, b) => b.size - a.size) // Сортируем по размеру
+ };
}
// Получить список каталогов
async getCatalogs(lang: 'ru' | 'en' = 'ru'): Promise {
+ const cacheKey = `catalogs_${lang}`;
+
+ // Проверяем кэш
+ const cached = this.getFromCache(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
try {
console.log('🔍 PartsIndex запрос каталогов:', { lang });
@@ -187,7 +258,11 @@ class PartsIndexService {
return [];
}
- return response.data.list;
+ const catalogs = response.data.list;
+ // Сохраняем в кэш на 1 час
+ this.setCache(cacheKey, catalogs, this.CATALOGS_TTL);
+
+ return catalogs;
} catch (error) {
console.error('❌ Ошибка запроса PartsIndex getCatalogs:', error);
return [];
@@ -196,6 +271,14 @@ class PartsIndexService {
// Получить группы каталога
async getCatalogGroups(catalogId: string, lang: 'ru' | 'en' = 'ru'): Promise {
+ const cacheKey = `groups_${catalogId}_${lang}`;
+
+ // Проверяем кэш
+ const cached = this.getFromCache(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
try {
console.log('🔍 PartsIndex запрос групп каталога:', { catalogId, lang });
@@ -218,21 +301,28 @@ class PartsIndexService {
return [];
}
+ let groups: PartsIndexGroup[];
+
// Если есть подгруппы, возвращаем их
if (groupData.subgroups.length > 0) {
console.log('📁 Найдено подгрупп:', groupData.subgroups.length);
- return groupData.subgroups;
+ groups = groupData.subgroups;
+ } else {
+ // Если подгрупп нет, создаем группу из самого каталога
+ console.log('📝 Подгрупп нет, возвращаем главную группу');
+ groups = [{
+ id: groupData.id,
+ name: groupData.name,
+ image: groupData.image,
+ subgroups: [],
+ entityNames: groupData.entityNames
+ }];
}
- // Если подгрупп нет, создаем группу из самого каталога
- console.log('📝 Подгрупп нет, возвращаем главную группу');
- return [{
- id: groupData.id,
- name: groupData.name,
- image: groupData.image,
- subgroups: [],
- entityNames: groupData.entityNames
- }];
+ // Сохраняем в кэш на 24 часа
+ this.setCache(cacheKey, groups, this.GROUPS_TTL);
+
+ return groups;
} catch (error) {
console.error('❌ Ошибка запроса PartsIndex getCatalogGroups:', error);
return [];
@@ -253,17 +343,26 @@ class PartsIndexService {
params?: Record;
} = {}
): Promise {
- try {
- const {
- lang = 'ru',
- limit = 25,
- page = 1,
- q,
- engineId,
- generationId,
- params
- } = options;
+ const {
+ lang = 'ru',
+ limit = 25,
+ page = 1,
+ q,
+ engineId,
+ generationId,
+ params
+ } = options;
+ // Создаем ключ кэша на основе всех параметров
+ const cacheKey = `entities_${catalogId}_${groupId}_${lang}_${limit}_${page}_${q || 'no-query'}_${engineId || 'no-engine'}_${generationId || 'no-generation'}_${JSON.stringify(params || {})}`;
+
+ // Проверяем кэш (кэшируем товары на короткое время)
+ const cached = this.getFromCache(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
+ try {
console.log('🔍 PartsIndex запрос товаров каталога:', {
catalogId,
groupId,
@@ -316,7 +415,11 @@ class PartsIndexService {
return null;
}
- return response.data;
+ const result = response.data;
+ // Сохраняем в кэш на 10 минут (товары могут изменяться)
+ this.setCache(cacheKey, result, this.ENTITIES_TTL);
+
+ return result;
} catch (error) {
console.error('❌ Ошибка запроса PartsIndex getCatalogEntities:', error);
return null;
@@ -335,15 +438,24 @@ class PartsIndexService {
q?: string;
} = {}
): Promise {
- try {
- const {
- lang = 'ru',
- engineId,
- generationId,
- params,
- q
- } = options;
+ const {
+ lang = 'ru',
+ engineId,
+ generationId,
+ params,
+ q
+ } = options;
+ // Создаем ключ кэша на основе всех параметров
+ const cacheKey = `params_${catalogId}_${groupId}_${lang}_${q || 'no-query'}_${engineId || 'no-engine'}_${generationId || 'no-generation'}_${JSON.stringify(params || {})}`;
+
+ // Проверяем кэш
+ const cached = this.getFromCache(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
+ try {
console.log('🔍 PartsIndex запрос параметров каталога:', {
catalogId,
groupId,
@@ -392,15 +504,27 @@ class PartsIndexService {
return null;
}
- return response.data;
+ const result = response.data;
+ // Сохраняем в кэш на 1 час
+ this.setCache(cacheKey, result, this.PARAMS_TTL);
+
+ return result;
} catch (error) {
console.error('❌ Ошибка запроса PartsIndex getCatalogParams:', error);
return null;
}
}
- // Получить полную структуру категорий с подкатегориями
+ // Получить полную структуру категорий с подкатегориями (оптимизированная версия)
async getCategoriesWithGroups(lang: 'ru' | 'en' = 'ru'): Promise> {
+ const cacheKey = `categories_with_groups_${lang}`;
+
+ // Проверяем кэш
+ const cached = this.getFromCache>(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
try {
console.log('🔍 PartsIndex запрос полной структуры категорий');
@@ -413,17 +537,43 @@ class PartsIndexService {
}
// Для каждого каталога получаем его группы
- const catalogsWithGroups = await Promise.all(
- catalogs.map(async (catalog) => {
- const groups = await this.getCatalogGroups(catalog.id, lang);
- return {
- ...catalog,
- groups
- };
- })
- );
+ // Ограничиваем количество одновременных запросов
+ const BATCH_SIZE = 3;
+ const catalogsWithGroups: Array = [];
- console.log('✅ PartsIndex полная структуря получена:', catalogsWithGroups.length, 'каталогов');
+ for (let i = 0; i < catalogs.length; i += BATCH_SIZE) {
+ const batch = catalogs.slice(i, i + BATCH_SIZE);
+
+ const batchResults = await Promise.all(
+ batch.map(async (catalog) => {
+ try {
+ const groups = await this.getCatalogGroups(catalog.id, lang);
+ return {
+ ...catalog,
+ groups
+ };
+ } catch (error) {
+ console.error(`❌ Ошибка загрузки групп для каталога ${catalog.id}:`, error);
+ return {
+ ...catalog,
+ groups: []
+ };
+ }
+ })
+ );
+
+ catalogsWithGroups.push(...batchResults);
+
+ // Небольшая задержка между батчами для снижения нагрузки на API
+ if (i + BATCH_SIZE < catalogs.length) {
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ }
+
+ console.log('✅ PartsIndex полная структура получена:', catalogsWithGroups.length, 'каталогов');
+
+ // Сохраняем в кэш на 24 часа
+ this.setCache(cacheKey, catalogsWithGroups, this.CATALOGS_TTL);
return catalogsWithGroups;
} catch (error) {