diff --git a/package-lock.json b/package-lock.json index 9660502..36ba4a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@types/jsonwebtoken": "^9.0.9", "@types/jspdf": "^1.3.3", "@types/pdfkit": "^0.14.0", + "@types/pg": "^8.15.4", "@types/qrcode": "^1.5.5", "@types/uuid": "^10.0.0", "axios": "^1.10.0", @@ -55,6 +56,7 @@ "lucide-react": "^0.513.0", "next": "15.3.3", "pdfkit": "^0.17.1", + "pg": "^8.16.3", "postcss": "^8.5.6", "prisma": "^6.9.0", "puppeteer": "^24.10.2", @@ -4834,6 +4836,17 @@ "@types/node": "*" } }, + "node_modules/@types/pg": { + "version": "8.15.4", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz", + "integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qrcode": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", @@ -10766,6 +10779,95 @@ "license": "MIT", "optional": true }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10837,6 +10939,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11914,6 +12055,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -13168,6 +13318,15 @@ "node": ">=0.8" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/package.json b/package.json index c873c34..f8120ed 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@types/jsonwebtoken": "^9.0.9", "@types/jspdf": "^1.3.3", "@types/pdfkit": "^0.14.0", + "@types/pg": "^8.15.4", "@types/qrcode": "^1.5.5", "@types/uuid": "^10.0.0", "axios": "^1.10.0", @@ -65,6 +66,7 @@ "lucide-react": "^0.513.0", "next": "15.3.3", "pdfkit": "^0.17.1", + "pg": "^8.16.3", "postcss": "^8.5.6", "prisma": "^6.9.0", "puppeteer": "^24.10.2", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bdbce0d..ced6a47 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -797,6 +797,43 @@ enum DiscountCodeType { PROMOCODE } +// Cart models for backend cart storage +model Cart { + id String @id @default(cuid()) + clientId String @unique // Can be authenticated client ID or anonymous session ID + items CartItem[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("carts") +} + +model CartItem { + id String @id @default(cuid()) + cartId String + productId String? // For internal products + offerKey String? // For external products (AutoEuro) + name String + description String + brand String + article String + price Float + currency String @default("RUB") + quantity Int + stock Int? + deliveryTime String? + warehouse String? + supplier String? + isExternal Boolean @default(false) + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) + + @@map("cart_items") +} + enum DeliveryType { COURIER PICKUP diff --git a/src/app/dashboard/kraja/page.tsx b/src/app/dashboard/kraja/page.tsx new file mode 100644 index 0000000..5aa56bc --- /dev/null +++ b/src/app/dashboard/kraja/page.tsx @@ -0,0 +1,332 @@ +"use client" + +import { useState } from 'react' +import { useQuery } from '@apollo/client' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui/tabs' +import { Badge } from '@/components/ui/badge' +import { + Search, + Shield, + Package, + Loader2, + ChevronRight, + Eye +} from 'lucide-react' +import { GET_PARTSINDEX_CATEGORIES, GET_PARTSAPI_CATEGORIES } from '@/lib/graphql/queries' +import { KrajaCategories } from '@/components/kraja/KrajaCategories' +import { KrajaCategoryItems } from '@/components/kraja/KrajaCategoryItems' +import { KrajaSavedTables } from '@/components/kraja/KrajaSavedTables' + +interface Category { + id: string + name: string + image?: string + groups?: Array<{ + id: string + name: string + image?: string + subgroups?: Array<{ + id: string + name: string + image?: string + entityNames?: Array<{ + id: string + name: string + }> + }> + entityNames?: Array<{ + id: string + name: string + }> + }> +} + +interface PartsAPICategory { + id: string + name: string + level: number + parentId?: string + children?: PartsAPICategory[] +} + +export default function KrajaPage() { + const [activeTab, setActiveTab] = useState<'partsindex' | 'partsapi' | 'saved'>('partsindex') + const [selectedCategory, setSelectedCategory] = useState(null) + const [selectedGroup, setSelectedGroup] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [viewingTable, setViewingTable] = useState<{categoryId: string, categoryType: string, tableName: string} | null>(null) + + // Загрузка категорий PartsIndex + const { data: partsIndexData, loading: partsIndexLoading, error: partsIndexError } = useQuery( + GET_PARTSINDEX_CATEGORIES, + { + variables: { lang: 'ru' }, + errorPolicy: 'all' + } + ) + + // Загрузка категорий PartsAPI + const { data: partsAPIData, loading: partsAPILoading, error: partsAPIError } = useQuery( + GET_PARTSAPI_CATEGORIES, + { + variables: { carId: 9877, carType: 'PC' }, + errorPolicy: 'all' + } + ) + + const partsIndexCategories = partsIndexData?.partsIndexCategoriesWithGroups || [] + const partsAPICategories = partsAPIData?.partsAPICategories || [] + + const handleCategorySelect = (category: Category | PartsAPICategory, group?: any) => { + setSelectedCategory(category) + setSelectedGroup(group || null) + } + + const handleBackToCategories = () => { + setSelectedCategory(null) + setSelectedGroup(null) + setViewingTable(null) + } + + const handleViewTable = (categoryId: string, categoryType: string, tableName: string) => { + setViewingTable({ categoryId, categoryType, tableName }) + setActiveTab('saved') + } + + const filteredPartsIndexCategories = partsIndexCategories.filter(category => + category.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + const filteredPartsAPICategories = partsAPICategories.filter(category => + category.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + // Если выбрана категория, показываем её товары + if (selectedCategory) { + return ( +
+
+ {/* Заголовок с кнопкой возврата */} +
+ +
+ +

Кража - {selectedCategory.name}

+ {selectedGroup && ( + <> + + {selectedGroup.name} + + )} +
+
+ + {/* Товары категории */} + +
+
+ ) + } + + // Если просматриваем сохраненную таблицу + if (viewingTable && activeTab === 'saved') { + return ( +
+
+ {/* Заголовок с кнопкой возврата */} +
+ +
+ +

Сохраненные данные - {viewingTable.tableName}

+ + {viewingTable.categoryType.toUpperCase()} + +
+
+ + {/* Содержимое сохраненной таблицы */} + +
+
+ ) + } + + return ( +
+
+ {/* Заголовок */} +
+ +
+

Кража

+

+ Просмотр категорий и товаров из PartsIndex и PartsAPI +

+
+
+ + {/* Поиск */} + {activeTab !== 'saved' && ( + + +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+
+
+ )} + + {/* Статистика */} +
+ + +
+ +
+
Категорий PartsIndex
+
{partsIndexCategories.length}
+
+
+
+
+ + + +
+ +
+
Категорий PartsAPI
+
{partsAPICategories.length}
+
+
+
+
+ + + +
+ +
+
Всего категорий
+
{partsIndexCategories.length + partsAPICategories.length}
+
+
+
+
+
+ + {/* Табы с категориями */} + setActiveTab(value as 'partsindex' | 'partsapi' | 'saved')}> + + + + PartsIndex + {partsIndexCategories.length} + + + + PartsAPI + {partsAPICategories.length} + + + + Сохраненные + + + + + {partsIndexLoading ? ( +
+ + Загрузка категорий PartsIndex... +
+ ) : partsIndexError ? ( + + +
+ Ошибка загрузки категорий PartsIndex: {partsIndexError.message} +
+
+
+ ) : ( + + )} +
+ + + {partsAPILoading ? ( +
+ + Загрузка категорий PartsAPI... +
+ ) : partsAPIError ? ( + + +
+ Ошибка загрузки категорий PartsAPI: {partsAPIError.message} +
+
+
+ ) : ( + + )} +
+ + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/kraja/KrajaCategories.tsx b/src/components/kraja/KrajaCategories.tsx new file mode 100644 index 0000000..4a95674 --- /dev/null +++ b/src/components/kraja/KrajaCategories.tsx @@ -0,0 +1,381 @@ +'use client' + +import { useState } from 'react' +import { useMutation } from '@apollo/client' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Package, + ChevronRight, + FolderOpen, + Layers, + Image as ImageIcon, + Download, + Loader2 +} from 'lucide-react' +import { FETCH_CATEGORY_PRODUCTS } from '@/lib/graphql/queries' +import toast from 'react-hot-toast' + +interface PartsIndexCategory { + id: string + name: string + image?: string + groups?: Array<{ + id: string + name: string + image?: string + subgroups?: Array<{ + id: string + name: string + image?: string + entityNames?: Array<{ + id: string + name: string + }> + }> + entityNames?: Array<{ + id: string + name: string + }> + }> +} + +interface PartsAPICategory { + id: string + name: string + level: number + parentId?: string + children?: PartsAPICategory[] +} + +interface KrajaCategoriesProps { + categories: PartsIndexCategory[] | PartsAPICategory[] + onCategorySelect: (category: PartsIndexCategory | PartsAPICategory, group?: any) => void + type: 'partsindex' | 'partsapi' +} + +export const KrajaCategories = ({ categories, onCategorySelect, type }: KrajaCategoriesProps) => { + const [expandedCategories, setExpandedCategories] = useState>(new Set()) + const [fetchingCategories, setFetchingCategories] = useState>(new Set()) + + const [fetchCategoryProducts] = useMutation(FETCH_CATEGORY_PRODUCTS, { + onCompleted: (data) => { + if (data.fetchCategoryProducts.success) { + toast.success(`✅ ${data.fetchCategoryProducts.message}`) + } else { + toast.error(`❌ ${data.fetchCategoryProducts.message}`) + } + }, + onError: (error) => { + toast.error(`❌ ${error.message}`) + } + }) + + const toggleCategory = (categoryId: string) => { + setExpandedCategories(prev => { + const newSet = new Set(prev) + if (newSet.has(categoryId)) { + newSet.delete(categoryId) + } else { + newSet.add(categoryId) + } + return newSet + }) + } + + const handleCategoryClick = (category: PartsIndexCategory | PartsAPICategory, group?: any) => { + onCategorySelect(category, group) + } + + const handleFetchProducts = async ( + category: PartsIndexCategory | PartsAPICategory, + group?: any, + fetchAll: boolean = false + ) => { + const fetchKey = group ? `${category.id}_${group.id}` : category.id + + setFetchingCategories(prev => new Set(prev).add(fetchKey)) + + try { + await fetchCategoryProducts({ + variables: { + input: { + categoryId: category.id, + categoryName: category.name, + categoryType: type.toUpperCase(), + groupId: group?.id, + groupName: group?.name, + fetchAll, + limit: fetchAll ? 1000 : 100 + } + } + }) + } catch (error) { + console.error('Fetch error:', error) + } finally { + setFetchingCategories(prev => { + const newSet = new Set(prev) + newSet.delete(fetchKey) + return newSet + }) + } + } + + if (!categories || categories.length === 0) { + return ( + + +
+ +

Категории не найдены

+
+
+
+ ) + } + + if (type === 'partsindex') { + const partsIndexCategories = categories as PartsIndexCategory[] + + return ( +
+ {partsIndexCategories.map((category) => ( + + +
+ {/* Заголовок категории */} +
+
+ {category.image ? ( + {category.name} + ) : ( + + )} +
+
+

{category.name}

+
+ + {category.groups?.length || 0} групп + +
+
+ +
+ + {/* Группы категории */} + {expandedCategories.has(category.id) && category.groups && ( +
+ {category.groups.map((group) => ( +
+
+ + + +
+ + {/* Подгруппы */} + {group.subgroups && group.subgroups.length > 0 && ( +
+ {group.subgroups.slice(0, 3).map((subgroup) => ( + + ))} + {group.subgroups.length > 3 && ( +
+ и ещё {group.subgroups.length - 3} подгрупп... +
+ )} +
+ )} +
+ ))} +
+ )} + + {/* Кнопки действий */} +
+ + + +
+
+
+
+ ))} +
+ ) + } + + // PartsAPI categories (tree structure) + const partsAPICategories = categories as PartsAPICategory[] + + const renderPartsAPICategory = (category: PartsAPICategory, level: number = 0) => ( +
0 ? 'ml-4' : ''}`}> + + +
+
+
+ +
+
+

{category.name}

+
+ + Уровень {category.level} + + {category.children && category.children.length > 0 && ( + + {category.children.length} подкатегорий + + )} +
+
+
+
+ + + + + {category.children && category.children.length > 0 && ( + + )} +
+
+
+
+ + {/* Подкатегории */} + {expandedCategories.has(category.id) && category.children && ( +
+ {category.children.map((child) => renderPartsAPICategory(child, level + 1))} +
+ )} +
+ ) + + return ( +
+ {partsAPICategories.map((category) => renderPartsAPICategory(category))} +
+ ) +} \ No newline at end of file diff --git a/src/components/kraja/KrajaCategoryItems.tsx b/src/components/kraja/KrajaCategoryItems.tsx new file mode 100644 index 0000000..92c10c8 --- /dev/null +++ b/src/components/kraja/KrajaCategoryItems.tsx @@ -0,0 +1,538 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useQuery } from '@apollo/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui/tabs' +import { + Package, + Search, + Loader2, + AlertCircle, + Eye, + Filter, + Grid, + List +} from 'lucide-react' +import { GET_PARTSINDEX_CATALOG_ENTITIES, GET_PARTSAPI_ARTICLES, GET_CATEGORY_PRODUCTS } from '@/lib/graphql/queries' + +interface PartsIndexCategory { + id: string + name: string + image?: string + groups?: Array<{ + id: string + name: string + image?: string + subgroups?: Array<{ + id: string + name: string + image?: string + entityNames?: Array<{ + id: string + name: string + }> + }> + entityNames?: Array<{ + id: string + name: string + }> + }> +} + +interface PartsAPICategory { + id: string + name: string + level: number + parentId?: string + children?: PartsAPICategory[] +} + +interface KrajaCategoryItemsProps { + category: PartsIndexCategory | PartsAPICategory + group?: any + categoryType: 'partsindex' | 'partsapi' + isViewingSavedData?: boolean +} + +interface PartsIndexEntity { + id: string + name: string + image?: string + brand?: string + description?: string + price?: number +} + +interface PartsAPIArticle { + supBrand: string + supId: number + productGroup: string + ptId: number + artSupBrand: string + artArticleNr: string + artId: string +} + +export const KrajaCategoryItems = ({ category, group, categoryType, isViewingSavedData = false }: KrajaCategoryItemsProps) => { + const [searchQuery, setSearchQuery] = useState('') + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') + const [currentPage, setCurrentPage] = useState(1) + const itemsPerPage = isViewingSavedData ? 100 : 20 + + // Для PartsIndex + const { + data: partsIndexData, + loading: partsIndexLoading, + error: partsIndexError, + refetch: refetchPartsIndex + } = useQuery(GET_PARTSINDEX_CATALOG_ENTITIES, { + variables: { + catalogId: categoryType === 'partsindex' ? category.id : undefined, + groupId: group?.id || undefined, + lang: 'ru', + limit: itemsPerPage, + page: currentPage, + q: searchQuery || undefined + }, + skip: categoryType !== 'partsindex' || !category.id, + errorPolicy: 'all' + }) + + // Для PartsAPI - используем strId (нужно преобразовать id в число) + const { + data: partsAPIData, + loading: partsAPILoading, + error: partsAPIError, + refetch: refetchPartsAPI + } = useQuery(GET_PARTSAPI_ARTICLES, { + variables: { + strId: categoryType === 'partsapi' ? parseInt(category.id) : undefined, + carId: 9877, + carType: 'PC' + }, + skip: categoryType !== 'partsapi' || !category.id || isViewingSavedData, + errorPolicy: 'all' + }) + + // Для просмотра сохраненных данных + const { + data: savedData, + loading: savedLoading, + error: savedError, + refetch: refetchSaved + } = useQuery(GET_CATEGORY_PRODUCTS, { + variables: { + categoryId: category.id, + categoryType: categoryType.toUpperCase(), + search: searchQuery || undefined, + limit: itemsPerPage, + offset: (currentPage - 1) * itemsPerPage + }, + skip: !isViewingSavedData, + errorPolicy: 'all' + }) + + // Обновляем поиск с задержкой + useEffect(() => { + const timeoutId = setTimeout(() => { + if (isViewingSavedData) { + refetchSaved() + } else if (categoryType === 'partsindex') { + refetchPartsIndex() + } else { + refetchPartsAPI() + } + }, 500) + + return () => clearTimeout(timeoutId) + }, [searchQuery, categoryType, isViewingSavedData, refetchPartsIndex, refetchPartsAPI, refetchSaved]) + + const isLoading = isViewingSavedData + ? savedLoading + : (categoryType === 'partsindex' ? partsIndexLoading : partsAPILoading) + + const error = isViewingSavedData + ? savedError + : (categoryType === 'partsindex' ? partsIndexError : partsAPIError) + + const items = isViewingSavedData + ? savedData?.getCategoryProducts?.products || [] + : (categoryType === 'partsindex' + ? partsIndexData?.partsIndexCatalogEntities?.list || [] + : partsAPIData?.partsAPIArticles || []) + + const renderPartsIndexItem = (item: PartsIndexEntity) => ( + + + {viewMode === 'grid' ? ( +
+
+
+ {item.image ? ( + {item.name} + ) : ( + + )} +
+
+

{item.name}

+ {item.brand && ( + + {item.brand} + + )} +
+
+ {item.description && ( +

{item.description}

+ )} + {item.price && ( +
+ {item.price.toLocaleString('ru-RU')} ₽ +
+ )} + +
+ ) : ( +
+
+ {item.image ? ( + {item.name} + ) : ( + + )} +
+
+

{item.name}

+
+ {item.brand && ( + + {item.brand} + + )} + {item.price && ( + + {item.price.toLocaleString('ru-RU')} ₽ + + )} +
+
+ +
+ )} +
+
+ ) + + const renderSavedItem = (item: any) => ( + + + {viewMode === 'grid' ? ( +
+
+
+ {item.image_url ? ( + {item.name} + ) : ( + + )} +
+
+

{item.name}

+ {item.brand && ( + + {item.brand} + + )} +
+
+ {item.description && ( +

{item.description}

+ )} + {item.price && ( +
+ {parseFloat(item.price).toLocaleString('ru-RU')} ₽ +
+ )} +
+ Сохранено: {new Date(item.created_at).toLocaleDateString('ru-RU')} +
+ +
+ ) : ( +
+
+ {item.image_url ? ( + {item.name} + ) : ( + + )} +
+
+

{item.name}

+
+ {item.brand && ( + + {item.brand} + + )} + {item.price && ( + + {parseFloat(item.price).toLocaleString('ru-RU')} ₽ + + )} + + {new Date(item.created_at).toLocaleDateString('ru-RU')} + +
+
+ +
+ )} +
+
+ ) + + const renderPartsAPIItem = (item: PartsAPIArticle, index: number) => ( + + + {viewMode === 'grid' ? ( +
+
+
+ +
+
+

{item.artArticleNr}

+
+ + {item.artSupBrand} + +
+
+
+
+

Группа: {item.productGroup}

+

Поставщик: {item.supBrand}

+

ID: {item.artId}

+
+ +
+ ) : ( +
+
+ +
+
+

{item.artArticleNr}

+
+ + {item.artSupBrand} + + {item.productGroup} +
+
+ +
+ )} +
+
+ ) + + return ( +
+ {/* Панель управления */} + + +
+ {/* Поиск */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + {/* Элементы управления */} +
+ + {items.length} товаров + + +
+ + +
+
+
+
+
+ + {/* Содержимое */} + {isLoading ? ( + + +
+ + Загрузка товаров... +
+
+
+ ) : error ? ( + + +
+ + Ошибка загрузки: {error.message} +
+
+
+ ) : items.length === 0 ? ( + + +
+ +

Товары не найдены

+

Попробуйте изменить критерии поиска

+
+
+
+ ) : ( +
+ {isViewingSavedData + ? items.map((item: any) => renderSavedItem(item)) + : (categoryType === 'partsindex' + ? items.map((item: PartsIndexEntity) => renderPartsIndexItem(item)) + : items.map((item: PartsAPIArticle, index: number) => renderPartsAPIItem(item, index)) + ) + } +
+ )} + + {/* Пагинация и статистика */} + {isViewingSavedData && savedData?.getCategoryProducts && ( + + +
+
+ Показано {((currentPage - 1) * itemsPerPage) + 1}-{Math.min(currentPage * itemsPerPage, savedData.getCategoryProducts.total)} из {savedData.getCategoryProducts.total.toLocaleString()} сохраненных товаров +
+
+ + + Страница {currentPage} + + +
+
+
+
+ )} + + {/* Пагинация для обычного просмотра */} + {!isViewingSavedData && items.length >= itemsPerPage && ( + + +
+ + + Страница {currentPage} + + +
+
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/components/kraja/KrajaSavedTables.tsx b/src/components/kraja/KrajaSavedTables.tsx new file mode 100644 index 0000000..484ea82 --- /dev/null +++ b/src/components/kraja/KrajaSavedTables.tsx @@ -0,0 +1,172 @@ +'use client' + +import { useState } from 'react' +import { useQuery, useMutation } from '@apollo/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Database, + Trash2, + Eye, + RefreshCw, + AlertCircle +} from 'lucide-react' +import { GET_CATEGORY_TABLES, DELETE_CATEGORY_TABLE } from '@/lib/graphql/queries' +import toast from 'react-hot-toast' + +interface CategoryTable { + tableName: string + categoryId: string + categoryType: string + recordCount: number +} + +interface KrajaSavedTablesProps { + onViewTable: (categoryId: string, categoryType: string, tableName: string) => void +} + +export const KrajaSavedTables = ({ onViewTable }: KrajaSavedTablesProps) => { + const { data, loading, error, refetch } = useQuery(GET_CATEGORY_TABLES, { + errorPolicy: 'all', + fetchPolicy: 'cache-and-network' + }) + + const [deleteCategoryTable] = useMutation(DELETE_CATEGORY_TABLE, { + onCompleted: () => { + toast.success('✅ Таблица удалена') + refetch() + }, + onError: (error) => { + toast.error(`❌ ${error.message}`) + } + }) + + const tables: CategoryTable[] = data?.getCategoryTables || [] + + const handleDeleteTable = async (categoryId: string, categoryType: string) => { + if (!confirm('Вы уверены, что хотите удалить эту таблицу? Все данные будут потеряны.')) { + return + } + + try { + await deleteCategoryTable({ + variables: { + categoryId, + categoryType: categoryType.toUpperCase() + } + }) + } catch (error) { + console.error('Delete error:', error) + } + } + + const getCategoryTypeColor = (type: string) => { + return type.toLowerCase() === 'partsindex' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800' + } + + const getCategoryTypeLabel = (type: string) => { + return type.toLowerCase() === 'partsindex' ? 'PartsIndex' : 'PartsAPI' + } + + if (loading) { + return ( + + +
+ + Загрузка сохраненных таблиц... +
+
+
+ ) + } + + if (error) { + return ( + + +
+ + Ошибка загрузки: {error.message} +
+
+
+ ) + } + + return ( + + +
+ + + Сохраненные таблицы + + +
+
+ + {tables.length === 0 ? ( +
+ +

Нет сохраненных таблиц

+

Используйте кнопки "Сохранить" в категориях для создания таблиц

+
+ ) : ( +
+ {tables.map((table) => ( +
+
+
+ +
+
+
+

{table.tableName}

+ + {getCategoryTypeLabel(table.categoryType)} + +
+
+ ID: {table.categoryId} + + {table.recordCount.toLocaleString()} записей + +
+
+
+ +
+ + + +
+
+ ))} +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index a6b6f36..2a5429e 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -15,7 +15,8 @@ import { Receipt, Palette, Star, - Image + Image, + Shield } from 'lucide-react' import { Button } from '@/components/ui/button' import { useAuth } from '@/components/providers/AuthProvider' @@ -35,6 +36,11 @@ const navigationItems = [ href: '/dashboard/catalog', icon: Package, }, + { + title: 'Кража', + href: '/dashboard/kraja', + icon: Shield, + }, { title: 'Навигация сайта', href: '/dashboard/navigation', diff --git a/src/lib/graphql/queries.ts b/src/lib/graphql/queries.ts index d02bb1e..46742fe 100644 --- a/src/lib/graphql/queries.ts +++ b/src/lib/graphql/queries.ts @@ -1467,6 +1467,138 @@ export const GET_PARTSINDEX_CATEGORIES = gql` } `; +// PartsAPI категории +export const GET_PARTSAPI_CATEGORIES = gql` + query GetPartsAPICategories($carId: Int!, $carType: CarType) { + partsAPICategories(carId: $carId, carType: $carType) { + id + name + level + parentId + children { + id + name + level + parentId + children { + id + name + level + parentId + } + } + } + } +`; + +// PartsIndex товары каталога +export const GET_PARTSINDEX_CATALOG_ENTITIES = gql` + query GetPartsIndexCatalogEntities( + $catalogId: String! + $groupId: String! + $lang: String + $limit: Int + $page: Int + $q: String + $engineId: String + $generationId: String + $params: String + ) { + partsIndexCatalogEntities( + catalogId: $catalogId + groupId: $groupId + lang: $lang + limit: $limit + page: $page + q: $q + engineId: $engineId + generationId: $generationId + params: $params + ) { + list { + id + name + image + brand + description + price + } + totalCount + page + limit + } + } +`; + +// PartsAPI артикулы +export const GET_PARTSAPI_ARTICLES = gql` + query GetPartsAPIArticles($strId: Int!, $carId: Int!, $carType: CarType) { + partsAPIArticles(strId: $strId, carId: $carId, carType: $carType) { + supBrand + supId + productGroup + ptId + artSupBrand + artArticleNr + artId + } + } +`; + +// Кража - мутации для работы с базой данных запчастей +export const FETCH_CATEGORY_PRODUCTS = gql` + mutation FetchCategoryProducts($input: FetchCategoryProductsInput!) { + fetchCategoryProducts(input: $input) { + success + message + insertedCount + tableName + } + } +`; + +export const GET_CATEGORY_TABLES = gql` + query GetCategoryTables { + getCategoryTables { + tableName + categoryId + categoryType + recordCount + } + } +`; + +export const DELETE_CATEGORY_TABLE = gql` + mutation DeleteCategoryTable($categoryId: String!, $categoryType: CategoryType!) { + deleteCategoryTable(categoryId: $categoryId, categoryType: $categoryType) + } +`; + +export const GET_CATEGORY_PRODUCTS = gql` + query GetCategoryProducts($categoryId: String!, $categoryType: CategoryType!, $search: String, $limit: Int, $offset: Int) { + getCategoryProducts(categoryId: $categoryId, categoryType: $categoryType, search: $search, limit: $limit, offset: $offset) { + products { + id + external_id + name + brand + article + description + image_url + price + category_id + category_name + category_type + group_id + group_name + created_at + updated_at + } + total + } + } +`; + // Hero Banners queries export const GET_HERO_BANNERS = gql` query GetHeroBanners { diff --git a/src/lib/graphql/resolvers.ts b/src/lib/graphql/resolvers.ts index cf52d78..09229d4 100644 --- a/src/lib/graphql/resolvers.ts +++ b/src/lib/graphql/resolvers.ts @@ -10,6 +10,7 @@ import { autoEuroService } from '../autoeuro-service' import { yooKassaService } from '../yookassa-service' import { partsAPIService } from '../partsapi-service' import { partsIndexService } from '../partsindex-service' +import { partsDb } from '../parts-db' import { yandexDeliveryService, YandexPickupPoint, getAddressSuggestions } from '../yandex-delivery-service' import { InvoiceService } from '../invoice-service' import * as csvWriter from 'csv-writer' @@ -2297,10 +2298,18 @@ export const resolvers = { // Поиск товаров и предложений searchProductOffers: async (_: unknown, { articleNumber, - brand + brand, + cartItems = [] }: { articleNumber: string; brand: string; + cartItems?: Array<{ + productId?: string; + offerKey?: string; + article: string; + brand: string; + quantity: number; + }>; }, context: Context) => { try { // Проверяем входные параметры @@ -2323,6 +2332,18 @@ export const resolvers = { const cleanBrand = brand.trim() console.log('🔍 GraphQL Resolver - поиск предложений для товара:', { articleNumber: cleanArticleNumber, brand: cleanBrand }) + console.log('🛒 Получено товаров в корзине:', cartItems.length) + + // Функция для проверки, находится ли товар в корзине + const isItemInCart = (productId?: string, offerKey?: string, article?: string, brand?: string): boolean => { + return cartItems.some(cartItem => { + // Проверяем по разным комбинациям идентификаторов + if (productId && cartItem.productId === productId) return true; + if (offerKey && cartItem.offerKey === offerKey) return true; + if (article && brand && cartItem.article === article && cartItem.brand === brand) return true; + return false; + }); + }; // 1. Поиск в нашей базе данных const internalProducts = await prisma.product.findMany({ @@ -2376,7 +2397,8 @@ export const resolvers = { warehouseName: offer.warehouse_name || null, rejects: offer.rejects || 0, supplier: 'AutoEuro', - canPurchase: true + canPurchase: true, + isInCart: isItemInCart(undefined, offer.offer_key, offer.code, offer.brand) })) console.log('🎯 GraphQL Resolver - создано внешних предложений:', externalOffers.length) @@ -2487,7 +2509,9 @@ export const resolvers = { deliveryDays: 1, available: (product.stock || 0) > 0, rating: 4.8, - supplier: 'Protek' + supplier: 'Protek', + canPurchase: true, + isInCart: isItemInCart(product.id, undefined, cleanArticleNumber, cleanBrand) })) // 6. Определяем название товара и собираем данные @@ -2549,6 +2573,24 @@ export const resolvers = { productName = `${cleanBrand} ${cleanArticleNumber}` } + // Расчет детализированной информации о наличии + const stockCalculation = { + totalInternalStock: internalOffers.reduce((sum, offer) => sum + (offer.quantity || 0), 0), + totalExternalStock: externalOffers.reduce((sum, offer) => sum + (offer.quantity || 0), 0), + availableInternalOffers: internalOffers.filter(offer => offer.available && offer.quantity > 0).length, + availableExternalOffers: externalOffers.filter(offer => offer.quantity > 0).length, + hasInternalStock: internalOffers.some(offer => offer.available && offer.quantity > 0), + hasExternalStock: externalOffers.some(offer => offer.quantity > 0), + totalStock: 0, + hasAnyStock: false + } + + stockCalculation.totalStock = stockCalculation.totalInternalStock + stockCalculation.totalExternalStock + stockCalculation.hasAnyStock = stockCalculation.hasInternalStock || stockCalculation.hasExternalStock + + // Проверяем, находится ли основной товар в корзине + const isMainProductInCart = isItemInCart(undefined, undefined, cleanArticleNumber, cleanBrand); + const result = { articleNumber: cleanArticleNumber, brand: cleanBrand, @@ -2561,25 +2603,49 @@ export const resolvers = { internalOffers, externalOffers, analogs, - hasInternalStock: internalOffers.some(offer => offer.available), - totalOffers: internalOffers.length + externalOffers.length + hasInternalStock: stockCalculation.hasInternalStock, + totalOffers: internalOffers.length + externalOffers.length, + stockCalculation, + isInCart: isMainProductInCart } + // Детализированное логирование результатов поиска console.log('✅ Результат поиска предложений:', { articleNumber: cleanArticleNumber, brand: cleanBrand, - internalOffers: result.internalOffers.length, - externalOffers: result.externalOffers.length, - analogs: result.analogs.length, - hasInternalStock: result.hasInternalStock + totalOffers: result.totalOffers, + stockStatus: { + hasAnyStock: stockCalculation.hasAnyStock, + totalStock: stockCalculation.totalStock, + internalStock: stockCalculation.totalInternalStock, + externalStock: stockCalculation.totalExternalStock, + availableInternalOffers: stockCalculation.availableInternalOffers, + availableExternalOffers: stockCalculation.availableExternalOffers + } }) - console.log('🔍 Детали результата:') - console.log('- Внутренние предложения:', result.internalOffers) - console.log('- Внешние предложения:', result.externalOffers.slice(0, 3)) - console.log('- Аналоги:', result.analogs.length) + console.log('📊 Детализация по предложениям:') + console.log(`- Внутренние предложения: ${result.internalOffers.length} (доступно: ${stockCalculation.availableInternalOffers}, общий сток: ${stockCalculation.totalInternalStock})`) + console.log(`- Внешние предложения: ${result.externalOffers.length} (доступно: ${stockCalculation.availableExternalOffers}, общий сток: ${stockCalculation.totalExternalStock})`) + console.log(`- Аналоги: ${result.analogs.length}`) + console.log(`- Итого в наличии: ${stockCalculation.hasAnyStock ? 'ДА' : 'НЕТ'} (${stockCalculation.totalStock} шт.)`) - // Сохраняем в историю поиска + // Логирование каждого предложения с деталями + if (result.internalOffers.length > 0) { + console.log('🏪 Внутренние предложения:') + result.internalOffers.forEach((offer, index) => { + console.log(` ${index + 1}. ${offer.productId} - ${offer.quantity} шт. (доступно: ${offer.available ? 'ДА' : 'НЕТ'}) - ${offer.price}₽ - склад: ${offer.warehouse}`) + }) + } + + if (result.externalOffers.length > 0) { + console.log('🌐 Внешние предложения (первые 5):') + result.externalOffers.slice(0, 5).forEach((offer, index) => { + console.log(` ${index + 1}. ${offer.code} (${offer.brand}) - ${offer.quantity} шт. - ${offer.price}₽ - поставщик: ${offer.supplier}`) + }) + } + + // Сохраняем в историю поиска с расширенной информацией await saveSearchHistory( context, `${cleanBrand} ${cleanArticleNumber}`, @@ -4145,6 +4211,27 @@ export const resolvers = { console.error('Ошибка получения баннера героя:', error) throw new Error('Не удалось получить баннер героя') } + }, + + // Корзина + getCart: async (_: unknown, {}, context: Context) => { + try { + const clientId = context.clientId; + if (!clientId) { + return null; + } + + const cart = await prisma.cart.findUnique({ + where: { clientId }, + include: { items: true } + }); + + return cart; + + } catch (error) { + console.error('❌ Error getting cart:', error); + return null; + } } }, @@ -9669,6 +9756,419 @@ export const resolvers = { } throw new Error('Не удалось удалить баннер героя') } + }, + + // Кража - мутации для работы с базой данных запчастей + fetchCategoryProducts: async (_: unknown, { input }: { input: any }, context: Context) => { + try { + if (!context.userId || context.userRole !== 'ADMIN') { + throw new Error('Недостаточно прав для выполнения операции') + } + + const { categoryId, categoryName, categoryType, groupId, groupName, limit = 100, fetchAll = false } = input + + console.log('🔍 Fetching products for category:', { + categoryId, + categoryName, + categoryType, + groupId, + groupName, + limit, + fetchAll + }) + + let products: any[] = [] + + if (categoryType === 'PARTSINDEX') { + if (!groupId) { + // If no groupId, try to fetch all groups for this category + console.log('🔍 No groupId provided, fetching all groups for category:', categoryId) + + const catalogGroups = await partsIndexService.getCatalogGroups(categoryId, 'ru') + console.log('✅ Found groups for category:', catalogGroups.length) + + if (catalogGroups.length === 0) { + return { + success: false, + message: 'No groups found for this PartsIndex category', + insertedCount: 0, + tableName: null + } + } + + // Fetch products from all groups (limit per group to avoid too much data) + const allProducts: any[] = [] + const maxProductsPerGroup = fetchAll + ? Math.max(5000, Math.floor(50000 / catalogGroups.length)) // Гораздо более щедрый лимит при fetchAll + : Math.max(1, Math.floor(limit / catalogGroups.length)) + + for (const group of catalogGroups.slice(0, 10)) { // Limit to first 10 groups + try { + let groupProducts: any[] = [] + + if (fetchAll) { + // Используем новый метод для получения ВСЕХ товаров группы + groupProducts = await partsIndexService.getAllCatalogEntities(categoryId, group.id, { + lang: 'ru', + maxItems: maxProductsPerGroup + }) + } else { + // Обычный метод с лимитом + const entitiesData = await partsIndexService.getCatalogEntities(categoryId, group.id, { + lang: 'ru', + limit: maxProductsPerGroup, + page: 1 + }) + groupProducts = entitiesData?.list || [] + } + + // Add group info to each product + const productsWithGroup = groupProducts.map(product => ({ + ...product, + groupId: group.id, + groupName: group.name + })) + + allProducts.push(...productsWithGroup) + console.log(`✅ Fetched ${groupProducts.length} products from group: ${group.name}`) + } catch (error) { + console.error(`❌ Error fetching products from group ${group.id}:`, error) + } + } + + products = allProducts + console.log('✅ Fetched total PartsIndex products:', products.length) + } else { + // Fetch from specific group + if (fetchAll) { + // Используем новый метод для получения ВСЕХ товаров группы + products = await partsIndexService.getAllCatalogEntities(categoryId, groupId, { + lang: 'ru', + maxItems: 50000 // Максимум товаров для одной группы + }) + } else { + // Обычный метод с лимитом + const entitiesData = await partsIndexService.getCatalogEntities(categoryId, groupId, { + lang: 'ru', + limit, + page: 1 + }) + products = entitiesData?.list || [] + } + + console.log('✅ Fetched PartsIndex products from group:', products.length) + } + + } else if (categoryType === 'PARTSAPI') { + const articlesData = await partsAPIService.getArticles(parseInt(categoryId), 9877, 'PC') + products = articlesData || [] + console.log('✅ Fetched PartsAPI products:', products.length) + + } else { + throw new Error('Invalid category type') + } + + if (products.length === 0) { + return { + success: false, + message: 'No products found for this category', + insertedCount: 0, + tableName: null + } + } + + console.log(`📊 About to insert ${products.length} products into database`) + console.log(`📋 Sample product data:`, products.slice(0, 3)) + + // Insert products into parts database + const insertedCount = await partsDb.insertProducts( + categoryId, + categoryName, + categoryType.toLowerCase() as 'partsindex' | 'partsapi', + products, + groupId, + groupName + ) + + console.log(`✅ Database insertion result: ${insertedCount} of ${products.length} products saved`) + + const tableName = `category_${categoryType.toLowerCase()}_${categoryId.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase()}` + + return { + success: true, + message: `Successfully fetched and saved ${insertedCount} products`, + insertedCount, + tableName + } + + } catch (error) { + console.error('❌ Error fetching category products:', error) + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred', + insertedCount: 0, + tableName: null + } + } + }, + + getCategoryTables: async (_: unknown, __: unknown, context: Context) => { + try { + if (!context.userId || context.userRole !== 'ADMIN') { + throw new Error('Недостаточно прав для выполнения операции') + } + + const tables = await partsDb.getCategoryTables() + return tables + + } catch (error) { + console.error('❌ Error getting category tables:', error) + return [] + } + }, + + deleteCategoryTable: async (_: unknown, { categoryId, categoryType }: { categoryId: string, categoryType: string }, context: Context) => { + try { + if (!context.userId || context.userRole !== 'ADMIN') { + throw new Error('Недостаточно прав для выполнения операции') + } + + await partsDb.deleteCategoryTable(categoryId, categoryType.toLowerCase() as 'partsindex' | 'partsapi') + return true + + } catch (error) { + console.error('❌ Error deleting category table:', error) + throw new Error('Failed to delete category table') + } + }, + + getCategoryProducts: async (_: unknown, { + categoryId, + categoryType, + search, + limit = 50, + offset = 0 + }: { + categoryId: string, + categoryType: string, + search?: string, + limit?: number, + offset?: number + }, context: Context) => { + try { + if (!context.userId || context.userRole !== 'ADMIN') { + throw new Error('Недостаточно прав для выполнения операции') + } + + const result = await partsDb.getProducts(categoryId, categoryType.toLowerCase() as 'partsindex' | 'partsapi', { + search, + limit, + offset + }) + + return { + products: result.products, + total: result.total + } + + } catch (error) { + console.error('❌ Error getting category products:', error) + return { + products: [], + total: 0 + } + } + }, + + // Корзина + addToCart: async (_: unknown, { input }: { input: any }, context: Context) => { + try { + const clientId = context.clientId; + if (!clientId) { + return { + success: false, + error: 'Клиент не идентифицирован' + }; + } + + console.log('🛒 Adding to cart for client:', clientId); + + // Находим или создаем корзину + let cart = await prisma.cart.findUnique({ + where: { clientId }, + include: { items: true } + }); + + if (!cart) { + cart = await prisma.cart.create({ + data: { clientId }, + include: { items: true } + }); + } + + // Проверяем, есть ли уже такой товар в корзине + const existingItem = cart.items.find(item => + (item.productId && input.productId && item.productId === input.productId) || + (item.offerKey && input.offerKey && item.offerKey === input.offerKey) || + (item.article === input.article && item.brand === input.brand) + ); + + if (existingItem) { + // Увеличиваем количество + await prisma.cartItem.update({ + where: { id: existingItem.id }, + data: { quantity: existingItem.quantity + input.quantity } + }); + } else { + // Добавляем новый товар + await prisma.cartItem.create({ + data: { + cartId: cart.id, + productId: input.productId, + offerKey: input.offerKey, + name: input.name, + description: input.description, + brand: input.brand, + article: input.article, + price: input.price, + currency: input.currency, + quantity: input.quantity, + stock: input.stock, + deliveryTime: input.deliveryTime, + warehouse: input.warehouse, + supplier: input.supplier, + isExternal: input.isExternal, + image: input.image + } + }); + } + + // Получаем обновленную корзину + const updatedCart = await prisma.cart.findUnique({ + where: { clientId }, + include: { items: true } + }); + + return { + success: true, + message: 'Товар добавлен в корзину', + cart: updatedCart + }; + + } catch (error) { + console.error('❌ Error adding to cart:', error); + return { + success: false, + error: 'Ошибка добавления товара в корзину' + }; + } + }, + + removeFromCart: async (_: unknown, { itemId }: { itemId: string }, context: Context) => { + try { + const clientId = context.clientId; + if (!clientId) { + return { + success: false, + error: 'Клиент не идентифицирован' + }; + } + + await prisma.cartItem.delete({ + where: { id: itemId } + }); + + const updatedCart = await prisma.cart.findUnique({ + where: { clientId }, + include: { items: true } + }); + + return { + success: true, + message: 'Товар удален из корзины', + cart: updatedCart + }; + + } catch (error) { + console.error('❌ Error removing from cart:', error); + return { + success: false, + error: 'Ошибка удаления товара из корзины' + }; + } + }, + + updateCartItemQuantity: async (_: unknown, { itemId, quantity }: { itemId: string; quantity: number }, context: Context) => { + try { + const clientId = context.clientId; + if (!clientId) { + return { + success: false, + error: 'Клиент не идентифицирован' + }; + } + + await prisma.cartItem.update({ + where: { id: itemId }, + data: { quantity: Math.max(1, quantity) } + }); + + const updatedCart = await prisma.cart.findUnique({ + where: { clientId }, + include: { items: true } + }); + + return { + success: true, + message: 'Количество товара обновлено', + cart: updatedCart + }; + + } catch (error) { + console.error('❌ Error updating cart item quantity:', error); + return { + success: false, + error: 'Ошибка обновления количества товара' + }; + } + }, + + clearCart: async (_: unknown, {}, context: Context) => { + try { + const clientId = context.clientId; + if (!clientId) { + return { + success: false, + error: 'Клиент не идентифицирован' + }; + } + + await prisma.cartItem.deleteMany({ + where: { + cart: { + clientId + } + } + }); + + const updatedCart = await prisma.cart.findUnique({ + where: { clientId }, + include: { items: true } + }); + + return { + success: true, + message: 'Корзина очищена', + cart: updatedCart + }; + + } catch (error) { + console.error('❌ Error clearing cart:', error); + return { + success: false, + error: 'Ошибка очистки корзины' + }; + } } } } \ No newline at end of file diff --git a/src/lib/graphql/typeDefs.ts b/src/lib/graphql/typeDefs.ts index 9e5c98e..49021e5 100644 --- a/src/lib/graphql/typeDefs.ts +++ b/src/lib/graphql/typeDefs.ts @@ -966,7 +966,8 @@ export const typeDefs = gql` # Поиск товаров и предложений searchProductOffers( articleNumber: String!, - brand: String! + brand: String!, + cartItems: [CartItemInput!] ): ProductOffersResult! getAnalogOffers(analogs: [AnalogOfferInput!]!): [AnalogProduct!] getBrandsByCode(code: String!): BrandsByCodeResponse! @@ -1060,6 +1061,9 @@ export const typeDefs = gql` # Новые поступления newArrivals(limit: Int = 8): [Product!]! + + # Корзина + getCart: Cart } type AuthPayload { @@ -1271,6 +1275,19 @@ export const typeDefs = gql` createHeroBanner(input: HeroBannerInput!): HeroBanner! updateHeroBanner(id: String!, input: HeroBannerUpdateInput!): HeroBanner! deleteHeroBanner(id: String!): Boolean! + + # Кража - работа с базой данных запчастей + fetchCategoryProducts(input: FetchCategoryProductsInput!): FetchCategoryProductsResult! + getCategoryTables: [CategoryTable!]! + deleteCategoryTable(categoryId: String!, categoryType: CategoryType!): Boolean! + getCategoryProducts(categoryId: String!, categoryType: CategoryType!, search: String, limit: Int, offset: Int): CategoryProductsResult! + + # Корзина + addToCart(input: AddToCartInput!): AddToCartResult! + removeFromCart(itemId: ID!): AddToCartResult! + updateCartItemQuantity(itemId: ID!, quantity: Int!): AddToCartResult! + clearCart: AddToCartResult! + getCart: Cart } input LoginInput { @@ -1707,6 +1724,69 @@ export const typeDefs = gql` category: String } + # Типы для корзины + input CartItemInput { + productId: String + offerKey: String + article: String! + brand: String! + quantity: Int! + } + + input AddToCartInput { + productId: String + offerKey: String + name: String! + description: String! + brand: String! + article: String! + price: Float! + currency: String! + quantity: Int! + stock: Int + deliveryTime: String + warehouse: String + supplier: String + isExternal: Boolean! + image: String + } + + type CartItem { + id: ID! + productId: String + offerKey: String + name: String! + description: String! + brand: String! + article: String! + price: Float! + currency: String! + quantity: Int! + stock: Int + deliveryTime: String + warehouse: String + supplier: String + isExternal: Boolean! + image: String + createdAt: String! + updatedAt: String! + } + + type Cart { + id: ID! + clientId: String! + items: [CartItem!]! + createdAt: String! + updatedAt: String! + } + + type AddToCartResult { + success: Boolean! + message: String + cart: Cart + error: String + } + # Типы для поиска товаров и предложений type ProductOffersResult { articleNumber: String! @@ -1722,6 +1802,19 @@ export const typeDefs = gql` analogs: [AnalogInfo!]! hasInternalStock: Boolean! totalOffers: Int! + stockCalculation: StockCalculation! + isInCart: Boolean! + } + + type StockCalculation { + totalInternalStock: Int! + totalExternalStock: Int! + availableInternalOffers: Int! + availableExternalOffers: Int! + hasInternalStock: Boolean! + hasExternalStock: Boolean! + totalStock: Int! + hasAnyStock: Boolean! } type PartsIndexImage { @@ -1748,6 +1841,7 @@ export const typeDefs = gql` rating: Float supplier: String! canPurchase: Boolean! + isInCart: Boolean! } type ExternalOffer { @@ -1760,6 +1854,7 @@ export const typeDefs = gql` deliveryTime: Int! deliveryTimeMax: Int! quantity: Int! + isInCart: Boolean! warehouse: String! warehouseName: String rejects: Float @@ -2342,4 +2437,58 @@ export const typeDefs = gql` isActive: Boolean sortOrder: Int } + + # Кража - типы для работы с базой данных запчастей + enum CategoryType { + PARTSINDEX + PARTSAPI + } + + input FetchCategoryProductsInput { + categoryId: String! + categoryName: String! + categoryType: CategoryType! + groupId: String + groupName: String + limit: Int + fetchAll: Boolean + } + + type FetchCategoryProductsResult { + success: Boolean! + message: String! + insertedCount: Int + tableName: String + } + + type CategoryTable { + tableName: String! + categoryId: String! + categoryType: String! + recordCount: Int! + } + + type CategoryProductsResult { + products: [CategoryProduct!]! + total: Int! + } + + type CategoryProduct { + id: Int! + external_id: String! + name: String! + brand: String + article: String + description: String + image_url: String + price: Float + category_id: String! + category_name: String! + category_type: String! + group_id: String + group_name: String + raw_data: JSON + created_at: DateTime! + updated_at: DateTime! + } ` \ No newline at end of file diff --git a/src/lib/parts-db.ts b/src/lib/parts-db.ts new file mode 100644 index 0000000..6287ad0 --- /dev/null +++ b/src/lib/parts-db.ts @@ -0,0 +1,361 @@ +import { Pool } from 'pg' + +class PartsDatabase { + private pool: Pool + + constructor() { + const connectionString = process.env.DATABASE_URL + if (!connectionString) { + throw new Error('DATABASE_URL environment variable is not set') + } + + this.pool = new Pool({ + connectionString, + max: 10, // Reduce concurrent connections to avoid overwhelming the DB + idleTimeoutMillis: 60000, // 1 minute + connectionTimeoutMillis: 10000, // 10 seconds + keepAlive: true, + keepAliveInitialDelayMillis: 10000, + }) + + console.log('🔌 Parts Database connection initialized (using main DATABASE_URL)') + } + + // Create table for a specific category + async createCategoryTable(categoryId: string, categoryName: string, categoryType: 'partsindex' | 'partsapi'): Promise { + const tableName = this.getCategoryTableName(categoryId, categoryType) + + try { + const query = ` + CREATE TABLE IF NOT EXISTS "${tableName}" ( + id SERIAL PRIMARY KEY, + external_id VARCHAR(255) UNIQUE NOT NULL, + name VARCHAR(500) NOT NULL, + brand VARCHAR(255), + article VARCHAR(255), + description TEXT, + image_url VARCHAR(500), + price DECIMAL(10,2), + category_id VARCHAR(255) NOT NULL, + category_name VARCHAR(500) NOT NULL, + category_type VARCHAR(20) NOT NULL, + group_id VARCHAR(255), + group_name VARCHAR(500), + raw_data JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + -- Create indexes for better performance + CREATE INDEX IF NOT EXISTS "idx_${tableName}_external_id" ON "${tableName}" (external_id); + CREATE INDEX IF NOT EXISTS "idx_${tableName}_category_id" ON "${tableName}" (category_id); + CREATE INDEX IF NOT EXISTS "idx_${tableName}_brand" ON "${tableName}" (brand); + CREATE INDEX IF NOT EXISTS "idx_${tableName}_article" ON "${tableName}" (article); + CREATE INDEX IF NOT EXISTS "idx_${tableName}_created_at" ON "${tableName}" (created_at); + + -- Create trigger to update updated_at timestamp + CREATE OR REPLACE FUNCTION update_${tableName}_timestamp() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; + END; + $$ language 'plpgsql'; + + DROP TRIGGER IF EXISTS trigger_update_${tableName}_timestamp ON "${tableName}"; + CREATE TRIGGER trigger_update_${tableName}_timestamp + BEFORE UPDATE ON "${tableName}" + FOR EACH ROW + EXECUTE FUNCTION update_${tableName}_timestamp(); + ` + + await this.pool.query(query) + console.log(`✅ Created table ${tableName} for category: ${categoryName}`) + } catch (error) { + console.error(`❌ Error creating table ${tableName}:`, error) + throw error + } + } + + // Insert or update products in category table + async insertProducts( + categoryId: string, + categoryName: string, + categoryType: 'partsindex' | 'partsapi', + products: any[], + groupId?: string, + groupName?: string + ): Promise { + const tableName = this.getCategoryTableName(categoryId, categoryType) + + console.log(`🔄 Starting to insert ${products.length} products into ${tableName}`) + + // First ensure table exists + await this.createCategoryTable(categoryId, categoryName, categoryType) + + let insertedCount = 0 + let errorCount = 0 + + // Process in smaller batches to reduce connection pressure + const batchSize = 50 + const batches: any[][] = [] + for (let i = 0; i < products.length; i += batchSize) { + batches.push(products.slice(i, i + batchSize)) + } + + console.log(`📦 Processing ${products.length} products in ${batches.length} batches of ${batchSize}`) + + for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) { + const batch = batches[batchIndex] + console.log(`🔄 Processing batch ${batchIndex + 1}/${batches.length} (${batch.length} products)`) + + for (let i = 0; i < batch.length; i++) { + const globalIndex = batchIndex * batchSize + i + const product = batch[i] + + // Retry logic with exponential backoff + let retryAttempts = 0 + const maxRetries = 3 + let success = false + + while (retryAttempts < maxRetries && !success) { + try { + const values = this.prepareProductData(product, categoryId, categoryName, categoryType, groupId, groupName) + + const query = ` + INSERT INTO "${tableName}" ( + external_id, name, brand, article, description, image_url, price, + category_id, category_name, category_type, group_id, group_name, raw_data + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + ON CONFLICT (external_id) + DO UPDATE SET + name = EXCLUDED.name, + brand = EXCLUDED.brand, + article = EXCLUDED.article, + description = EXCLUDED.description, + image_url = EXCLUDED.image_url, + price = EXCLUDED.price, + group_id = EXCLUDED.group_id, + group_name = EXCLUDED.group_name, + raw_data = EXCLUDED.raw_data, + updated_at = CURRENT_TIMESTAMP + ` + + await this.pool.query(query, values) + insertedCount++ + success = true + + // Log progress every 100 insertions + if (insertedCount % 100 === 0) { + console.log(`📊 Progress: ${insertedCount}/${products.length} products inserted into ${tableName}`) + } + } catch (error: any) { + retryAttempts++ + + // Check if it's a network/connection error that might be retryable + if (error.code === 'ENOTFOUND' || error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') { + if (retryAttempts < maxRetries) { + const delayMs = Math.pow(2, retryAttempts) * 1000 // Exponential backoff: 2s, 4s, 8s + console.log(`🔄 Retry ${retryAttempts}/${maxRetries} for product ${globalIndex + 1} after ${delayMs}ms delay`) + await new Promise(resolve => setTimeout(resolve, delayMs)) + continue + } + } + + // If max retries exceeded or non-retryable error + errorCount++ + console.error(`❌ Error inserting product ${globalIndex + 1}/${products.length} into ${tableName} (after ${retryAttempts} retries):`, error) + console.error(`❌ Product data:`, product) + break + } + } + } + + // Small delay between batches to allow DB to recover + if (batchIndex < batches.length - 1) { + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + + console.log(`✅ Insertion complete for ${tableName}:`) + console.log(` - Successfully inserted/updated: ${insertedCount} products`) + console.log(` - Errors: ${errorCount} products`) + console.log(` - Total processed: ${products.length} products`) + + return insertedCount + } + + // Get products from category table + async getProducts( + categoryId: string, + categoryType: 'partsindex' | 'partsapi', + options: { + limit?: number + offset?: number + search?: string + } = {} + ): Promise<{ products: any[], total: number }> { + const tableName = this.getCategoryTableName(categoryId, categoryType) + const { limit = 50, offset = 0, search } = options + + try { + let whereClause = '' + let searchParams: any[] = [] + + if (search) { + whereClause = 'WHERE (name ILIKE $1 OR brand ILIKE $1 OR article ILIKE $1 OR description ILIKE $1)' + searchParams = [`%${search}%`] + } + + // Count total + const countQuery = `SELECT COUNT(*) FROM "${tableName}" ${whereClause}` + const countResult = await this.pool.query(countQuery, searchParams) + const total = parseInt(countResult.rows[0].count) + + // Get products + const dataQuery = ` + SELECT * FROM "${tableName}" + ${whereClause} + ORDER BY created_at DESC + LIMIT $${searchParams.length + 1} OFFSET $${searchParams.length + 2} + ` + const dataResult = await this.pool.query(dataQuery, [...searchParams, limit, offset]) + + return { + products: dataResult.rows, + total + } + } catch (error) { + console.error(`❌ Error getting products from ${tableName}:`, error) + return { products: [], total: 0 } + } + } + + // Get all category tables + async getCategoryTables(): Promise<{ tableName: string, categoryId: string, categoryType: string, recordCount: number }[]> { + try { + const query = ` + SELECT + tablename, + schemaname + FROM pg_tables + WHERE schemaname = 'public' + AND (tablename LIKE 'category_partsindex_%' OR tablename LIKE 'category_partsapi_%') + ORDER BY tablename + ` + + const result = await this.pool.query(query) + const tables: { tableName: string, categoryId: string, categoryType: string, recordCount: number }[] = [] + + for (const row of result.rows) { + const tableName = row.tablename + + // Parse category info from table name + const [, categoryType, categoryId] = tableName.split('_') + + // Get record count + const countQuery = `SELECT COUNT(*) FROM "${tableName}"` + const countResult = await this.pool.query(countQuery) + const recordCount = parseInt(countResult.rows[0].count) + + tables.push({ + tableName, + categoryId, + categoryType, + recordCount + }) + } + + return tables + } catch (error) { + console.error('❌ Error getting category tables:', error) + return [] + } + } + + // Delete category table + async deleteCategoryTable(categoryId: string, categoryType: 'partsindex' | 'partsapi'): Promise { + const tableName = this.getCategoryTableName(categoryId, categoryType) + + try { + await this.pool.query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`) + console.log(`✅ Deleted table ${tableName}`) + } catch (error) { + console.error(`❌ Error deleting table ${tableName}:`, error) + throw error + } + } + + // Helper method to generate table name + private getCategoryTableName(categoryId: string, categoryType: 'partsindex' | 'partsapi'): string { + // Sanitize category ID for use in table name + const sanitizedId = categoryId.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase() + return `category_${categoryType}_${sanitizedId}` + } + + // Helper method to prepare product data + private prepareProductData( + product: any, + categoryId: string, + categoryName: string, + categoryType: 'partsindex' | 'partsapi', + groupId?: string, + groupName?: string + ): any[] { + if (categoryType === 'partsindex') { + return [ + product.id || product.external_id || `${Date.now()}_${Math.random()}`, + product.name || '', + product.brand || '', + product.article || '', + product.description || '', + product.image || '', + product.price ? parseFloat(product.price) : null, + categoryId, + categoryName, + categoryType, + groupId || null, + groupName || null, + JSON.stringify(product) + ] + } else { + // PartsAPI + return [ + product.artId || `${Date.now()}_${Math.random()}`, + product.artArticleNr || '', + product.artSupBrand || '', + product.artArticleNr || '', + product.productGroup || '', + '', // no image for PartsAPI + null, // no price for PartsAPI + categoryId, + categoryName, + categoryType, + groupId || null, + groupName || null, + JSON.stringify(product) + ] + } + } + + // Test database connection + async testConnection(): Promise { + try { + await this.pool.query('SELECT 1') + console.log('✅ Parts database connection test successful') + return true + } catch (error) { + console.error('❌ Parts database connection test failed:', error) + return false + } + } + + // Close connection pool + async close(): Promise { + await this.pool.end() + console.log('🔌 Parts database connection closed') + } +} + +// Export singleton instance +export const partsDb = new PartsDatabase() \ No newline at end of file diff --git a/src/lib/partsindex-service.ts b/src/lib/partsindex-service.ts index b1f1995..5359c55 100644 --- a/src/lib/partsindex-service.ts +++ b/src/lib/partsindex-service.ts @@ -329,6 +329,87 @@ class PartsIndexService { } } + // Новый метод: получить ВСЕ товары каталога (с пагинацией) + async getAllCatalogEntities( + catalogId: string, + groupId: string, + options: { + lang?: 'ru' | 'en'; + q?: string; + engineId?: string; + generationId?: string; + params?: Record; + maxItems?: number; + } = {} + ): Promise { + const { + lang = 'ru', + q, + engineId, + generationId, + params, + maxItems = 10000 + } = options; + + try { + console.log('🔍 PartsIndex запрос ВСЕХ товаров каталога:', { + catalogId, + groupId, + lang, + q, + maxItems + }); + + const allEntities: PartsIndexEntity[] = []; + let currentPage = 1; + const itemsPerPage = 100; // Увеличиваем размер страницы для эффективности + let hasMorePages = true; + + while (hasMorePages && allEntities.length < maxItems) { + const response = await this.getCatalogEntities(catalogId, groupId, { + lang, + limit: itemsPerPage, + page: currentPage, + q, + engineId, + generationId, + params + }); + + if (!response || !response.list || response.list.length === 0) { + hasMorePages = false; + break; + } + + allEntities.push(...response.list); + + console.log(`📄 Страница ${currentPage}: получено ${response.list.length} товаров, всего: ${allEntities.length}`); + + // Проверяем, есть ли следующая страница + hasMorePages = response.pagination && response.pagination.page.next !== null && response.list.length === itemsPerPage; + currentPage++; + + // Защита от бесконечного цикла + if (currentPage > 100) { + console.warn('⚠️ Достигнут лимит страниц (100), прерываем загрузку'); + break; + } + + // Небольшая задержка между запросами, чтобы не перегружать API + if (hasMorePages) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + console.log(`✅ PartsIndex получено всего товаров: ${allEntities.length}`); + return allEntities; + + } catch (error) { + console.error('❌ Ошибка получения всех товаров PartsIndex:', error); + return []; + } + } + // Новый метод: получить товары каталога async getCatalogEntities( catalogId: string, diff --git a/stack.env b/stack.env index 87bc089..28f01d4 100644 --- a/stack.env +++ b/stack.env @@ -5,6 +5,9 @@ CMS_PORT=3000 # Подключение к внешней PostgreSQL базе DATABASE_URL=postgresql://username:password@your-db-host:5432/protekauto_cms +# База данных для сохранения данных запчастей +PARTSDB_URL=postgresql://username:password@your-db-host:5432/protekauto_parts + # ===== АВТОРИЗАЦИЯ ===== # Секретный ключ для NextAuth (генерируйте случайно) NEXTAUTH_SECRET=your-super-secret-key-here-change-me