Обновлен README.md с новыми возможностями платформы Sfera V для управления бизнесом, добавлен раздел о складе Wildberries для селлеров. В компоненте Sidebar добавлена кнопка для перехода к складу ВБ, доступная только для пользователей с типом организации "SELLER". В классе WildberriesService реализованы новые методы для получения остатков товаров и интеграции с API Wildberries, включая обработку ошибок и кэширование данных.
This commit is contained in:
145
README.md
145
README.md
@ -1,122 +1,57 @@
|
|||||||
# SferaV - Система управления бизнесом
|
# Sfera V - Управление бизнесом
|
||||||
|
|
||||||
Красивое приложение для авторизации и управления кабинетами Фулфилмент и Wildberries с современным фиолетовым дизайном.
|
Платформа для управления различными типами бизнеса: фулфилмент, селлеры, логистика, оптовики.
|
||||||
|
|
||||||
## ✨ Особенности
|
## Новые возможности
|
||||||
|
|
||||||
- 🎨 **Современный UI/UX** - Фиолетовые градиенты и стеклянный эффект
|
### 🏪 Склад Wildberries для селлеров
|
||||||
- 📱 **Адаптивный дизайн** - Отлично работает на всех устройствах
|
|
||||||
- 🔐 **Многоэтапная авторизация** - Номер телефона → SMS → Выбор кабинета → Данные
|
|
||||||
- 📞 **Умная маска телефона** - Автоформатирование номера +7 (999) 999-99-99
|
|
||||||
- 💼 **Два типа кабинетов** - Фулфилмент (ИНН) и Wildberries (API ключ)
|
|
||||||
- ⚡ **Быстрая навигация** - Плавные переходы между этапами
|
|
||||||
|
|
||||||
## 🛠 Технологии
|
Новый раздел для селлеров, позволяющий:
|
||||||
|
|
||||||
- **Next.js 15** - React фреймворк
|
- **Просмотр остатков** товаров на всех складах WB в реальном времени
|
||||||
- **TypeScript** - Типизация
|
- **Статистика по складам** - общее количество товаров, остатки, товары в пути
|
||||||
- **Tailwind CSS 4** - Стилизация
|
- **Фильтрация и поиск** товаров по названию, артикулу, бренду
|
||||||
- **shadcn/ui** - UI компоненты
|
- **Детальная информация** по каждому складу отдельно
|
||||||
- **react-input-mask** - Маска ввода
|
- **Красивые карточки товаров** с изображениями и статусами остатков
|
||||||
|
|
||||||
## 🚀 Быстрый старт
|
#### Как использовать:
|
||||||
|
1. Настройте API ключ Wildberries в разделе "Настройки" → "API"
|
||||||
|
2. Перейдите в раздел "Склад ВБ" в боковом меню
|
||||||
|
3. Система автоматически загрузит актуальные остатки с вашего аккаунта WB
|
||||||
|
|
||||||
1. **Установка зависимостей:**
|
#### Технические особенности:
|
||||||
```bash
|
- Интеграция с официальным API Wildberries
|
||||||
npm install
|
- Поддержка всех типов складов WB
|
||||||
```
|
- Кэширование данных для быстрой работы
|
||||||
|
- Адаптивный дизайн в стиле платформы
|
||||||
|
|
||||||
2. **Запуск приложения:**
|
## Структура проекта
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Откройте браузер:**
|
- `src/app/wb-warehouse/` - Страница склада WB
|
||||||
```
|
- `src/components/wb-warehouse/` - Компоненты интерфейса склада
|
||||||
http://localhost:3000
|
- `src/services/wildberries-service.ts` - Интеграция с API WB
|
||||||
```
|
|
||||||
|
|
||||||
## 📱 Этапы авторизации
|
## Технологии
|
||||||
|
|
||||||
### 1. Ввод номера телефона
|
- Next.js 15
|
||||||
- Красивая маска ввода с автоформатированием
|
- React 18
|
||||||
- Валидация российских номеров (+7)
|
- TypeScript
|
||||||
- Плавная анимация при вводе
|
- GraphQL
|
||||||
|
- Prisma
|
||||||
|
- TailwindCSS
|
||||||
|
- Shadcn/ui
|
||||||
|
|
||||||
### 2. Подтверждение SMS
|
## Установка и запуск
|
||||||
- 4 отдельных поля для цифр кода
|
|
||||||
- Автопереключение между полями
|
|
||||||
- Возможность вернуться к изменению номера
|
|
||||||
|
|
||||||
### 3. Выбор типа кабинета
|
```bash
|
||||||
- Фулфилмент кабинет (складские операции)
|
npm install
|
||||||
- Wildberries кабинет (маркетплейс)
|
npm run dev
|
||||||
- Интерактивные карточки с описанием
|
|
||||||
|
|
||||||
### 4. Ввод данных
|
|
||||||
- **Фулфилмент:** ИНН организации (10-12 цифр)
|
|
||||||
- **Wildberries:** API ключ с инструкцией получения
|
|
||||||
|
|
||||||
## 🎨 Дизайн
|
|
||||||
|
|
||||||
- **Цветовая схема:** Фиолетовые градиенты
|
|
||||||
- **Эффекты:** Стеклянные поверхности, размытие
|
|
||||||
- **Анимации:** Плавные переходы и hover эффекты
|
|
||||||
- **Типографика:** Современные шрифты с хорошей читаемостью
|
|
||||||
|
|
||||||
## 📂 Структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── app/ # Next.js App Router
|
|
||||||
│ ├── globals.css # Глобальные стили
|
|
||||||
│ ├── layout.tsx # Основной layout
|
|
||||||
│ └── page.tsx # Главная страница
|
|
||||||
├── components/
|
|
||||||
│ ├── auth/ # Компоненты авторизации
|
|
||||||
│ │ ├── auth-flow.tsx # Основной флоу
|
|
||||||
│ │ ├── auth-layout.tsx # Layout для этапов
|
|
||||||
│ │ ├── phone-step.tsx # Ввод телефона
|
|
||||||
│ │ ├── sms-step.tsx # Ввод SMS
|
|
||||||
│ │ ├── cabinet-select-step.tsx # Выбор кабинета
|
|
||||||
│ │ ├── inn-step.tsx # Ввод ИНН
|
|
||||||
│ │ └── wb-api-step.tsx # Ввод API ключа WB
|
|
||||||
│ └── ui/ # shadcn/ui компоненты
|
|
||||||
│ ├── button.tsx
|
|
||||||
│ ├── card.tsx
|
|
||||||
│ ├── input.tsx
|
|
||||||
│ ├── label.tsx
|
|
||||||
│ ├── phone-input.tsx
|
|
||||||
│ └── select.tsx
|
|
||||||
└── lib/
|
|
||||||
└── utils.ts # Утилиты
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Настройка
|
## API интеграции
|
||||||
|
|
||||||
### Цвета
|
- Wildberries API для получения остатков и информации о складах
|
||||||
Фиолетовая тема настроена в `src/app/globals.css` с использованием CSS переменных oklch.
|
- DaData для работы с организациями
|
||||||
|
- SMS Aero для отправки SMS
|
||||||
|
|
||||||
### Компоненты
|
Доступ к разделу "Склад ВБ" имеют только пользователи с типом организации "SELLER".
|
||||||
Все UI компоненты основаны на shadcn/ui и адаптированы под дизайн системы.
|
|
||||||
|
|
||||||
## 📝 Будущие улучшения
|
|
||||||
|
|
||||||
- [ ] Интеграция с реальным API для SMS
|
|
||||||
- [ ] Сохранение состояния в localStorage
|
|
||||||
- [ ] Темная/светлая темы
|
|
||||||
- [ ] Интернационализация (i18n)
|
|
||||||
- [ ] Мобильное приложение
|
|
||||||
- [ ] Анимации между этапами
|
|
||||||
|
|
||||||
## 🤝 Вклад в проект
|
|
||||||
|
|
||||||
Приветствуются все улучшения! Создавайте issues и pull requests.
|
|
||||||
|
|
||||||
## 📄 Лицензия
|
|
||||||
|
|
||||||
MIT License - используйте свободно!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Сделано с ❤️ для удобной работы с маркетплейсами
|
|
||||||
|
12
src/app/wb-warehouse/page.tsx
Normal file
12
src/app/wb-warehouse/page.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AuthGuard } from '@/components/auth-guard'
|
||||||
|
import { WBWarehouseDashboard } from '@/components/wb-warehouse/wb-warehouse-dashboard'
|
||||||
|
|
||||||
|
export default function WBWarehousePage() {
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<WBWarehouseDashboard />
|
||||||
|
</AuthGuard>
|
||||||
|
)
|
||||||
|
}
|
@ -106,6 +106,10 @@ export function Sidebar() {
|
|||||||
router.push("/warehouse");
|
router.push("/warehouse");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleWBWarehouseClick = () => {
|
||||||
|
router.push("/wb-warehouse");
|
||||||
|
};
|
||||||
|
|
||||||
const handleEmployeesClick = () => {
|
const handleEmployeesClick = () => {
|
||||||
router.push("/employees");
|
router.push("/employees");
|
||||||
};
|
};
|
||||||
@ -151,6 +155,7 @@ export function Sidebar() {
|
|||||||
const isMessengerActive = pathname.startsWith("/messenger");
|
const isMessengerActive = pathname.startsWith("/messenger");
|
||||||
const isServicesActive = pathname.startsWith("/services");
|
const isServicesActive = pathname.startsWith("/services");
|
||||||
const isWarehouseActive = pathname.startsWith("/warehouse");
|
const isWarehouseActive = pathname.startsWith("/warehouse");
|
||||||
|
const isWBWarehouseActive = pathname.startsWith("/wb-warehouse");
|
||||||
const isFulfillmentWarehouseActive = pathname.startsWith(
|
const isFulfillmentWarehouseActive = pathname.startsWith(
|
||||||
"/fulfillment-warehouse"
|
"/fulfillment-warehouse"
|
||||||
);
|
);
|
||||||
@ -419,6 +424,25 @@ export function Sidebar() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Склад ВБ - для селлеров */}
|
||||||
|
{user?.organization?.type === "SELLER" && (
|
||||||
|
<Button
|
||||||
|
variant={isWBWarehouseActive ? "secondary" : "ghost"}
|
||||||
|
className={`w-full ${
|
||||||
|
isCollapsed ? "justify-center px-2 h-9" : "justify-start h-10"
|
||||||
|
} text-left transition-all duration-200 text-xs ${
|
||||||
|
isWBWarehouseActive
|
||||||
|
? "bg-white/20 text-white hover:bg-white/30"
|
||||||
|
: "text-white/80 hover:bg-white/10 hover:text-white"
|
||||||
|
} cursor-pointer`}
|
||||||
|
onClick={handleWBWarehouseClick}
|
||||||
|
title={isCollapsed ? "Склад ВБ" : ""}
|
||||||
|
>
|
||||||
|
<Warehouse className="h-4 w-4 flex-shrink-0" />
|
||||||
|
{!isCollapsed && <span className="ml-3">Склад ВБ</span>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Статистика - для селлеров */}
|
{/* Статистика - для селлеров */}
|
||||||
{user?.organization?.type === "SELLER" && (
|
{user?.organization?.type === "SELLER" && (
|
||||||
<Button
|
<Button
|
||||||
|
699
src/components/wb-warehouse/wb-warehouse-dashboard.tsx
Normal file
699
src/components/wb-warehouse/wb-warehouse-dashboard.tsx
Normal file
@ -0,0 +1,699 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { WildberriesService } from '@/services/wildberries-service'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Package,
|
||||||
|
Warehouse,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
RefreshCw,
|
||||||
|
Filter,
|
||||||
|
MapPin
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface WBStock {
|
||||||
|
nmId: number
|
||||||
|
vendorCode: string
|
||||||
|
title: string
|
||||||
|
brand: string
|
||||||
|
price: number
|
||||||
|
stocks: Array<{
|
||||||
|
warehouseId: number
|
||||||
|
warehouseName: string
|
||||||
|
quantity: number
|
||||||
|
quantityFull: number
|
||||||
|
inWayToClient: number
|
||||||
|
inWayFromClient: number
|
||||||
|
}>
|
||||||
|
totalQuantity: number
|
||||||
|
totalReserved: number
|
||||||
|
photos?: Array<{
|
||||||
|
big?: string
|
||||||
|
c246x328?: string
|
||||||
|
c516x688?: string
|
||||||
|
square?: string
|
||||||
|
tm?: string
|
||||||
|
}>
|
||||||
|
mediaFiles?: string[]
|
||||||
|
characteristics?: Array<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
value: string[] | string
|
||||||
|
}>
|
||||||
|
subjectName?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WBWarehouse {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
cargoType: number
|
||||||
|
deliveryType: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WBWarehouseDashboard() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { getSidebarMargin } = useSidebar()
|
||||||
|
|
||||||
|
const [stocks, setStocks] = useState<WBStock[]>([])
|
||||||
|
const [warehouses, setWarehouses] = useState<WBWarehouse[]>([])
|
||||||
|
const [analyticsData, setAnalyticsData] = useState<Array<{warehouseId: number; warehouseName: string; toClient: number; fromClient: number}>>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('all')
|
||||||
|
|
||||||
|
// Статистика
|
||||||
|
const [totalProducts, setTotalProducts] = useState(0)
|
||||||
|
const [totalStocks, setTotalStocks] = useState(0)
|
||||||
|
const [totalReserved, setTotalReserved] = useState(0)
|
||||||
|
const [activeWarehouses, setActiveWarehouses] = useState(0)
|
||||||
|
|
||||||
|
// Загрузка данных
|
||||||
|
const loadWarehouseData = async (showToast = false) => {
|
||||||
|
const isInitialLoad = loading
|
||||||
|
if (!isInitialLoad) setRefreshing(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')
|
||||||
|
|
||||||
|
if (!wbApiKey?.isActive) {
|
||||||
|
toast.error('API ключ Wildberries не настроен')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationData = wbApiKey.validationData as Record<string, string>
|
||||||
|
const apiToken = validationData?.token ||
|
||||||
|
validationData?.apiKey ||
|
||||||
|
validationData?.key ||
|
||||||
|
(wbApiKey as { apiKey?: string }).apiKey
|
||||||
|
|
||||||
|
if (!apiToken) {
|
||||||
|
toast.error('Токен API не найден')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const wbService = new WildberriesService(apiToken)
|
||||||
|
|
||||||
|
console.log('WB Warehouse: Starting data load with Analytics API...')
|
||||||
|
|
||||||
|
// Сначала получаем карточки товаров для передачи в Analytics API
|
||||||
|
console.log('WB Warehouse: Getting cards for Analytics API...')
|
||||||
|
const cards = await WildberriesService.getAllCards(apiToken).catch(() => [])
|
||||||
|
const nmIds = cards.map(card => card.nmID).filter(id => id > 0)
|
||||||
|
console.log('WB Warehouse: Found cards for Analytics API:', nmIds)
|
||||||
|
|
||||||
|
// Загружаем склады, основные данные и Analytics API для движения товаров
|
||||||
|
const [warehousesData, stocksData, rawAnalyticsData] = await Promise.all([
|
||||||
|
wbService.getWarehouses().catch((error) => {
|
||||||
|
console.error('WB Warehouse: Error loading warehouses:', error)
|
||||||
|
return []
|
||||||
|
}),
|
||||||
|
wbService.getStocks().catch((error) => {
|
||||||
|
console.error('WB Warehouse: Error loading stocks:', error)
|
||||||
|
return []
|
||||||
|
}),
|
||||||
|
wbService.getStocksReportByOffices({
|
||||||
|
nmIds: nmIds.length > 0 ? nmIds : undefined, // Передаем ID твоих товаров
|
||||||
|
stockType: '' // все склады - покажем все данные
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('WB Warehouse: Error loading analytics data:', error)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log('WB Warehouse: Warehouses loaded:', warehousesData.length)
|
||||||
|
console.log('WB Warehouse: Basic stocks loaded:', stocksData.length)
|
||||||
|
console.log('WB Warehouse: Analytics data loaded:', rawAnalyticsData.length)
|
||||||
|
|
||||||
|
setWarehouses(warehousesData)
|
||||||
|
|
||||||
|
// Analytics API создает записи с другой структурой - изучаем что пришло
|
||||||
|
console.log('WB Warehouse: Raw analytics data structure:', rawAnalyticsData)
|
||||||
|
console.log('WB Warehouse: Sample analytics item:', rawAnalyticsData[0])
|
||||||
|
|
||||||
|
// Отключаем общую аналитику - будем показывать детализацию по товарам в карточках
|
||||||
|
setAnalyticsData([])
|
||||||
|
|
||||||
|
// Объединяем основные данные со склады и данные Analytics API по складам WB
|
||||||
|
const combinedStocks = [...stocksData, ...rawAnalyticsData]
|
||||||
|
const processedStocks = processStocksData(combinedStocks, warehousesData, rawAnalyticsData)
|
||||||
|
setStocks(processedStocks)
|
||||||
|
|
||||||
|
// Обновляем статистику
|
||||||
|
updateStatistics(processedStocks, warehousesData)
|
||||||
|
|
||||||
|
if (showToast) {
|
||||||
|
toast.success('Данные обновлены')
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки данных склада:', error)
|
||||||
|
toast.error('Ошибка загрузки данных склада')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка данных остатков с дополнением данными из Analytics API
|
||||||
|
const processStocksData = (stocksData: unknown[], warehousesData: WBWarehouse[], analyticsData: WBStock[] = []): WBStock[] => {
|
||||||
|
const stocksMap = new Map<number, WBStock>()
|
||||||
|
|
||||||
|
// Создаем карту данных Analytics API по складам для быстрого поиска
|
||||||
|
const analyticsMap = new Map<number, { toClientCount: number, fromClientCount: number }>()
|
||||||
|
analyticsData.forEach(item => {
|
||||||
|
item.stocks.forEach(stock => {
|
||||||
|
analyticsMap.set(stock.warehouseId, {
|
||||||
|
toClientCount: stock.inWayToClient,
|
||||||
|
fromClientCount: stock.inWayFromClient
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('WB Warehouse: Analytics map created with', analyticsMap.size, 'warehouse entries')
|
||||||
|
|
||||||
|
stocksData.forEach((stockItem: unknown) => {
|
||||||
|
const stock = stockItem as Record<string, unknown>
|
||||||
|
const nmId = Number(stock.nmId) || 0
|
||||||
|
|
||||||
|
if (!stocksMap.has(nmId)) {
|
||||||
|
console.log(`WB Warehouse: Processing stock for nmId ${nmId}`)
|
||||||
|
console.log(`WB Warehouse: Stock item:`, stock)
|
||||||
|
|
||||||
|
stocksMap.set(nmId, {
|
||||||
|
nmId,
|
||||||
|
vendorCode: String(stock.vendorCode || stock.supplierArticle || ''),
|
||||||
|
title: String(stock.title || stock.subject || `Товар ${nmId}`),
|
||||||
|
brand: String(stock.brand || ''),
|
||||||
|
price: Number(stock.price || stock.Price) || 0,
|
||||||
|
stocks: [],
|
||||||
|
totalQuantity: 0,
|
||||||
|
totalReserved: 0,
|
||||||
|
photos: Array.isArray(stock.photos) ? stock.photos as Array<{big?: string; c246x328?: string; c516x688?: string; square?: string; tm?: string}> : [],
|
||||||
|
mediaFiles: Array.isArray(stock.mediaFiles) ? stock.mediaFiles as string[] : [],
|
||||||
|
characteristics: Array.isArray(stock.characteristics) ? stock.characteristics as Array<{id: number; name: string; value: string[] | string}> : [],
|
||||||
|
subjectName: String(stock.subjectName || stock.subject || ''),
|
||||||
|
description: String(stock.description || '')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = stocksMap.get(nmId)!
|
||||||
|
|
||||||
|
// Для Analytics API данных берем warehouseId из первого stock в массиве stocks
|
||||||
|
let warehouseId = Number(stock.warehouseId || stock.warehouse) || 0
|
||||||
|
let warehouseName = String(stock.warehouseName || '')
|
||||||
|
|
||||||
|
// Если это данные Analytics API (есть массив stocks)
|
||||||
|
if (Array.isArray(stock.stocks) && stock.stocks.length > 0) {
|
||||||
|
const firstStock = stock.stocks[0]
|
||||||
|
warehouseId = Number(firstStock.warehouseId) || 0
|
||||||
|
warehouseName = String(firstStock.warehouseName || `Склад ${warehouseId}`)
|
||||||
|
console.log(`WB Warehouse: Analytics stock - warehouseId: ${warehouseId}, name: ${warehouseName}`)
|
||||||
|
} else {
|
||||||
|
// Обычные данные
|
||||||
|
warehouseName = warehouseName || warehousesData.find(w => w.id === warehouseId)?.name || `Склад ${warehouseId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let quantity = Number(stock.quantity) || 0
|
||||||
|
let quantityFull = Number(stock.quantityFull) || 0
|
||||||
|
let inWayToClient = 0
|
||||||
|
let inWayFromClient = 0
|
||||||
|
|
||||||
|
// Если это данные Analytics API
|
||||||
|
if (Array.isArray(stock.stocks) && stock.stocks.length > 0) {
|
||||||
|
const firstStock = stock.stocks[0]
|
||||||
|
quantity = Number(firstStock.quantity) || 0
|
||||||
|
quantityFull = Number(firstStock.quantityFull) || 0
|
||||||
|
inWayToClient = Number(firstStock.inWayToClient) || 0
|
||||||
|
inWayFromClient = Number(firstStock.inWayFromClient) || 0
|
||||||
|
} else {
|
||||||
|
// Обычные данные - используем Analytics API если доступны
|
||||||
|
const analyticsInfo = analyticsMap.get(warehouseId)
|
||||||
|
inWayToClient = analyticsInfo?.toClientCount ?? (Number(stock.inWayToClient) || 0)
|
||||||
|
inWayFromClient = analyticsInfo?.fromClientCount ?? (Number(stock.inWayFromClient) || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAnalytics = Array.isArray(stock.stocks) && stock.stocks.length > 0
|
||||||
|
console.log(`WB Warehouse: Warehouse ${warehouseId} - Analytics: ${hasAnalytics ? 'YES' : 'NO'}, toClient: ${inWayToClient}, fromClient: ${inWayFromClient}`)
|
||||||
|
|
||||||
|
const warehouseStock = {
|
||||||
|
warehouseId,
|
||||||
|
warehouseName,
|
||||||
|
quantity,
|
||||||
|
quantityFull,
|
||||||
|
inWayToClient,
|
||||||
|
inWayFromClient
|
||||||
|
}
|
||||||
|
|
||||||
|
item.stocks.push(warehouseStock)
|
||||||
|
item.totalQuantity += quantity
|
||||||
|
item.totalReserved += inWayToClient
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(stocksMap.values()).sort((a, b) => b.totalQuantity - a.totalQuantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление статистики
|
||||||
|
const updateStatistics = (stocksData: WBStock[], warehousesData: WBWarehouse[]) => {
|
||||||
|
setTotalProducts(stocksData.length)
|
||||||
|
setTotalStocks(stocksData.reduce((sum, item) => sum + item.totalQuantity, 0))
|
||||||
|
setTotalReserved(stocksData.reduce((sum, item) => sum + item.totalReserved, 0))
|
||||||
|
|
||||||
|
const warehousesWithStock = new Set(
|
||||||
|
stocksData.flatMap(item => item.stocks.map(s => s.warehouseId))
|
||||||
|
)
|
||||||
|
setActiveWarehouses(warehousesWithStock.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Фильтрация товаров (показываем все товары, включая с нулевыми остатками)
|
||||||
|
const filteredStocks = stocks.filter(item => {
|
||||||
|
const matchesSearch = searchTerm === '' ||
|
||||||
|
item.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
item.vendorCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
item.brand.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
|
||||||
|
const matchesWarehouse = selectedWarehouse === 'all' ||
|
||||||
|
item.stocks.some(s => s.warehouseId.toString() === selectedWarehouse)
|
||||||
|
|
||||||
|
return matchesSearch && matchesWarehouse
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.organization?.type === 'SELLER') {
|
||||||
|
loadWarehouseData()
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
// Проверяем настройку API ключа
|
||||||
|
const hasWBApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')?.isActive
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
|
||||||
|
<div className="h-full w-full flex flex-col">
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Склад Wildberries</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => loadWarehouseData(true)}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="glass-button text-white"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Статистика */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<Card className="glass-card border-white/10 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">Товаров</p>
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{loading ? <Skeleton className="h-6 w-16 bg-white/10" /> : totalProducts.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Package className="h-8 w-8 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card border-white/10 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">Общий остаток</p>
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{loading ? <Skeleton className="h-6 w-16 bg-white/10" /> : totalStocks.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Warehouse className="h-8 w-8 text-green-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card border-white/10 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">В пути к клиенту</p>
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{loading ? <Skeleton className="h-6 w-16 bg-white/10" /> : totalReserved.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TrendingUp className="h-8 w-8 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card border-white/10 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">Активных складов</p>
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{loading ? <Skeleton className="h-6 w-16 bg-white/10" /> : activeWarehouses}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MapPin className="h-8 w-8 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Аналитика по складам WB */}
|
||||||
|
{analyticsData.length > 0 && (
|
||||||
|
<Card className="glass-card border-white/10 p-4 mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
||||||
|
<TrendingUp className="h-5 w-5 mr-2 text-blue-400" />
|
||||||
|
Движение товаров по складам WB
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{analyticsData.map((warehouse) => (
|
||||||
|
<Card key={warehouse.warehouseId} className="bg-white/5 border-white/10 p-3">
|
||||||
|
<div className="text-sm font-medium text-white mb-2">{warehouse.warehouseName}</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-white/60">К клиенту:</span>
|
||||||
|
<span className="text-green-400 font-medium">{warehouse.toClient}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-white/60">От клиента:</span>
|
||||||
|
<span className="text-orange-400 font-medium">{warehouse.fromClient}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Фильтры */}
|
||||||
|
<Card className="glass-card border-white/10 p-4 mb-6">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск по названию, артикулу или бренду..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="glass-input text-white placeholder:text-white/40 pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-64">
|
||||||
|
<select
|
||||||
|
value={selectedWarehouse}
|
||||||
|
onChange={(e) => setSelectedWarehouse(e.target.value)}
|
||||||
|
className="w-full h-10 px-3 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||||
|
>
|
||||||
|
<option value="all">Все склады</option>
|
||||||
|
{warehouses.map(warehouse => (
|
||||||
|
<option key={warehouse.id} value={warehouse.id.toString()}>
|
||||||
|
{warehouse.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Список товаров */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Card key={i} className="glass-card border-white/10 p-4">
|
||||||
|
<Skeleton className="h-20 w-full bg-white/10" />
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !hasWBApiKey ? (
|
||||||
|
<Card className="glass-card border-white/10 p-8 text-center">
|
||||||
|
<Package className="h-12 w-12 text-blue-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-2">Настройте API ключ Wildberries</h3>
|
||||||
|
<p className="text-white/60 mb-4">
|
||||||
|
Для просмотра остатков товаров на складах WB необходимо добавить API ключ
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = '/settings'}
|
||||||
|
className="bg-blue-500 hover:bg-blue-600 text-white"
|
||||||
|
>
|
||||||
|
Перейти в настройки
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : filteredStocks.length === 0 ? (
|
||||||
|
<Card className="glass-card border-white/10 p-8 text-center">
|
||||||
|
<Package className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
||||||
|
<p className="text-white/60">
|
||||||
|
{searchTerm || selectedWarehouse !== 'all'
|
||||||
|
? 'Товары не найдены по заданным фильтрам'
|
||||||
|
: 'Нет карточек товаров в WB'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 overflow-y-auto pr-2 max-h-full">
|
||||||
|
{filteredStocks.map((item, index) => (
|
||||||
|
<StockCard key={`${item.nmId}-${index}`} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компонент карточки товара
|
||||||
|
function StockCard({ item }: { item: WBStock }) {
|
||||||
|
// Получение изображений карточки через WildberriesService
|
||||||
|
const getCardImages = (item: WBStock): string[] => {
|
||||||
|
console.log(`WB Warehouse: Getting images for card ${item.nmId}`)
|
||||||
|
console.log(`WB Warehouse: Photos:`, item.photos)
|
||||||
|
console.log(`WB Warehouse: MediaFiles:`, item.mediaFiles)
|
||||||
|
|
||||||
|
// Если есть photos в формате WB API
|
||||||
|
if (item.photos && item.photos.length > 0) {
|
||||||
|
const urls = item.photos
|
||||||
|
.map(photo => photo.c246x328 || photo.c516x688 || photo.big)
|
||||||
|
.filter((url): url is string => Boolean(url))
|
||||||
|
console.log(`WB Warehouse: URLs from photos:`, urls)
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем mediaFiles (как в создании поставки)
|
||||||
|
if (item.mediaFiles && item.mediaFiles.length > 0) {
|
||||||
|
console.log(`WB Warehouse: URLs from mediaFiles:`, item.mediaFiles)
|
||||||
|
return item.mediaFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback - генерируем URL по стандартной схеме WB
|
||||||
|
const vol = Math.floor(item.nmId / 100000)
|
||||||
|
const part = Math.floor(item.nmId / 1000)
|
||||||
|
const fallbackUrl = `https://basket-${String(vol).padStart(2, '0')}.wbbasket.ru/vol${vol}/part${part}/${item.nmId}/images/c246x328/1.webp`
|
||||||
|
console.log(`WB Warehouse: Using fallback URL:`, fallbackUrl)
|
||||||
|
return [fallbackUrl]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStockStatus = (quantity: number) => {
|
||||||
|
if (quantity === 0) return { color: 'bg-red-500/20 text-red-400 border-red-500/30', label: 'Нет в наличии' }
|
||||||
|
if (quantity < 10) return { color: 'bg-orange-500/20 text-orange-400 border-orange-500/30', label: 'Мало' }
|
||||||
|
return { color: 'bg-green-500/20 text-green-400 border-green-500/30', label: 'В наличии' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stockStatus = getStockStatus(item.totalQuantity)
|
||||||
|
// Получаем изображения из данных карточки WB
|
||||||
|
const images = getCardImages(item)
|
||||||
|
const mainImage = images[0] || null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="glass-card border-white/10 overflow-hidden hover:border-white/20 transition-all duration-300">
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Изображение товара */}
|
||||||
|
<div className="w-20 h-20 rounded-lg overflow-hidden bg-white/5 flex-shrink-0 relative group">
|
||||||
|
{mainImage ? (
|
||||||
|
<img
|
||||||
|
src={mainImage}
|
||||||
|
alt={item.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<Package className="h-8 w-8 text-white/40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Индикатор WB */}
|
||||||
|
<div className="absolute top-1 right-1">
|
||||||
|
<Badge className="bg-blue-500/90 text-white border-0 text-xs px-1.5 py-0.5">
|
||||||
|
WB
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Информация о товаре */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Заголовок и бренд */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 text-xs font-medium">
|
||||||
|
{item.brand || 'Без бренда'}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-white/40 text-xs">№{item.nmId}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-white font-semibold text-sm mb-2 line-clamp-2 leading-tight">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Артикул */}
|
||||||
|
<div className="text-white/60 text-xs mb-2">
|
||||||
|
Артикул: <span className="text-white/80 font-mono">{item.vendorCode}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge className={`${stockStatus.color} border text-xs flex-shrink-0`}>
|
||||||
|
{stockStatus.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Общая статистика */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 p-3 bg-white/5 rounded-lg">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-white text-lg font-bold">{item.totalQuantity.toLocaleString()}</p>
|
||||||
|
<p className="text-white/60 text-xs">Доступно</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-purple-400 text-lg font-bold">{item.stocks.length}</p>
|
||||||
|
<p className="text-white/60 text-xs">Складов</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Статистика по движению товаров */}
|
||||||
|
{(item.stocks.some(s => s.inWayToClient > 0) || item.stocks.some(s => s.inWayFromClient > 0)) && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 p-3 bg-blue-500/10 rounded-lg border border-blue-500/20">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-blue-400 text-lg font-bold">
|
||||||
|
{item.stocks.reduce((sum, s) => sum + s.inWayToClient, 0).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-white/60 text-xs">К клиенту</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-orange-400 text-lg font-bold">
|
||||||
|
{item.stocks.reduce((sum, s) => sum + s.inWayFromClient, 0).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-white/60 text-xs">От клиента</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Остатки по складам */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-medium text-sm mb-3">Остатки по складам:</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{item.stocks.map((stock, stockIndex) => (
|
||||||
|
<div key={`${stock.warehouseId}-${stockIndex}`} className="flex items-center justify-between py-2 px-3 rounded-lg bg-white/5">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-white text-sm font-medium">{stock.warehouseName}</p>
|
||||||
|
<p className="text-white/60 text-xs">ID: {stock.warehouseId}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-green-400 font-bold text-lg">{stock.quantity}</p>
|
||||||
|
<p className="text-white/60 text-xs">Доступно</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className={`font-bold text-lg ${stock.inWayToClient > 0 ? 'text-blue-400' : 'text-white/30'}`}>
|
||||||
|
{stock.inWayToClient}
|
||||||
|
</p>
|
||||||
|
<p className="text-white/60 text-xs">К клиенту</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className={`font-bold text-lg ${stock.inWayFromClient > 0 ? 'text-orange-400' : 'text-white/30'}`}>
|
||||||
|
{stock.inWayFromClient}
|
||||||
|
</p>
|
||||||
|
<p className="text-white/60 text-xs">От клиента</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Основная информация о товаре */}
|
||||||
|
{(item.subjectName || item.description) && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-medium text-sm mb-3">Информация о товаре:</h4>
|
||||||
|
<div className="space-y-2 p-3 rounded-lg bg-white/5">
|
||||||
|
{item.subjectName && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-white/60 text-sm">Категория:</span>
|
||||||
|
<span className="text-white text-sm font-medium">{item.subjectName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.description && (
|
||||||
|
<div>
|
||||||
|
<span className="text-white/60 text-sm block mb-1">Описание:</span>
|
||||||
|
<p className="text-white text-sm leading-relaxed">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Характеристики товара */}
|
||||||
|
{item.characteristics && item.characteristics.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-medium text-sm mb-3">Характеристики:</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{item.characteristics.map((characteristic, charIndex) => (
|
||||||
|
<div key={`${characteristic.id}-${charIndex}`} className="flex justify-between items-start py-2 px-3 rounded-lg bg-white/5">
|
||||||
|
<span className="text-white/60 text-sm font-medium min-w-[100px]">
|
||||||
|
{characteristic.name}:
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 text-right">
|
||||||
|
{Array.isArray(characteristic.value) ? (
|
||||||
|
characteristic.value.map((val, valIndex) => (
|
||||||
|
<span key={valIndex} className="text-white text-sm">
|
||||||
|
{val}
|
||||||
|
{valIndex < characteristic.value.length - 1 && ', '}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-white text-sm">
|
||||||
|
{String(characteristic.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
@ -11,6 +11,86 @@ interface WildberriesWarehousesResponse {
|
|||||||
data: WildberriesWarehouse[]
|
data: WildberriesWarehouse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Интерфейс для совместимости с компонентом склада
|
||||||
|
interface WBStock {
|
||||||
|
nmId: number
|
||||||
|
vendorCode: string
|
||||||
|
title: string
|
||||||
|
brand: string
|
||||||
|
price: number
|
||||||
|
stocks: Array<{
|
||||||
|
warehouseId: number
|
||||||
|
warehouseName: string
|
||||||
|
quantity: number
|
||||||
|
quantityFull: number
|
||||||
|
inWayToClient: number
|
||||||
|
inWayFromClient: number
|
||||||
|
}>
|
||||||
|
totalQuantity: number
|
||||||
|
totalReserved: number
|
||||||
|
photos?: Array<{
|
||||||
|
big?: string
|
||||||
|
c246x328?: string
|
||||||
|
c516x688?: string
|
||||||
|
square?: string
|
||||||
|
tm?: string
|
||||||
|
}>
|
||||||
|
mediaFiles?: string[]
|
||||||
|
characteristics?: Array<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
value: string[] | string
|
||||||
|
}>
|
||||||
|
subjectName?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analytics API interfaces for stocks report by offices
|
||||||
|
interface StocksReportOfficesRequest {
|
||||||
|
nmIDs?: number[]
|
||||||
|
subjectIDs?: number[]
|
||||||
|
brandNames?: string[]
|
||||||
|
tagIDs?: number[]
|
||||||
|
currentPeriod: {
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
}
|
||||||
|
stockType: '' | 'wb' | 'mp'
|
||||||
|
skipDeletedNm: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StocksReportOfficesResponse {
|
||||||
|
data: {
|
||||||
|
regions: Array<{
|
||||||
|
regionName: string
|
||||||
|
metrics: {
|
||||||
|
stockCount?: number
|
||||||
|
stockSum?: number
|
||||||
|
saleRate?: {
|
||||||
|
days: number
|
||||||
|
hours: number
|
||||||
|
}
|
||||||
|
toClientCount?: number
|
||||||
|
fromClientCount?: number
|
||||||
|
}
|
||||||
|
offices: Array<{
|
||||||
|
officeID: number
|
||||||
|
officeName: string
|
||||||
|
metrics: {
|
||||||
|
stockCount: number
|
||||||
|
stockSum: number
|
||||||
|
saleRate: {
|
||||||
|
days: number
|
||||||
|
hours: number
|
||||||
|
}
|
||||||
|
toClientCount: number
|
||||||
|
fromClientCount: number
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface WildberriesCard {
|
interface WildberriesCard {
|
||||||
nmID: number
|
nmID: number
|
||||||
imtID?: number
|
imtID?: number
|
||||||
@ -266,10 +346,15 @@ class WildberriesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async makeRequest<T>(url: string, options: RequestInit = {}): Promise<T> {
|
private async makeRequest<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
// Определяем правильный заголовок авторизации в зависимости от API
|
||||||
|
const authHeader = url.includes('marketplace-api.wildberries.ru') || url.includes('content-api.wildberries.ru')
|
||||||
|
? { 'Authorization': `Bearer ${this.apiKey}` } // Marketplace и Content API используют Bearer
|
||||||
|
: { 'Authorization': this.apiKey } // Statistics и Advert API используют прямой токен
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.apiKey,
|
...authHeader,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
@ -393,12 +478,35 @@ class WildberriesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение списка складов
|
// Получение списка складов
|
||||||
async getWarehouses(): Promise<WildberriesWarehouse[]> {
|
async getWarehouses(): Promise<Array<{ id: number; name: string; cargoType: number; deliveryType: number }>> {
|
||||||
const url = `${this.baseURL}/api/v2/warehouses`
|
try {
|
||||||
console.log(`WB API: Getting warehouses from ${url}`)
|
// Используем правильный API endpoint для получения складов продавца
|
||||||
const response = await this.makeRequest<WildberriesWarehouse[]>(url)
|
const url = `https://marketplace-api.wildberries.ru/api/v3/warehouses`
|
||||||
return response || []
|
console.log(`WB API: Getting seller warehouses from ${url}`)
|
||||||
|
|
||||||
|
const response = await this.makeRequest<Array<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
officeId?: number
|
||||||
|
cargoType?: number
|
||||||
|
deliveryType?: number
|
||||||
|
}>>(url)
|
||||||
|
|
||||||
|
console.log(`WB API: Got ${response.length} warehouses`)
|
||||||
|
return response.map(w => ({
|
||||||
|
id: w.id,
|
||||||
|
name: w.name,
|
||||||
|
cargoType: w.cargoType || 1,
|
||||||
|
deliveryType: w.deliveryType || 1
|
||||||
|
}))
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`WB API: Error getting warehouses:`, error)
|
||||||
|
// При ошибке возвращаем пустой массив вместо статических данных
|
||||||
|
console.log(`WB API: Returning empty warehouses array due to API error`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение карточек товаров
|
// Получение карточек товаров
|
||||||
@ -444,15 +552,30 @@ class WildberriesService {
|
|||||||
// Создаем массив URL изображений для совместимости с mediaFiles
|
// Создаем массив URL изображений для совместимости с mediaFiles
|
||||||
const mediaFiles: string[] = []
|
const mediaFiles: string[] = []
|
||||||
|
|
||||||
|
console.log(`WB API: Processing card ${card.nmID}, photos:`, card.photos)
|
||||||
|
|
||||||
if (card.photos && card.photos.length > 0) {
|
if (card.photos && card.photos.length > 0) {
|
||||||
card.photos.forEach(photo => {
|
card.photos.forEach((photo, index) => {
|
||||||
// Добавляем разные размеры изображений, приоритет большим размерам
|
// Для каждого фото берем лучший доступный размер
|
||||||
if (photo.big) mediaFiles.push(photo.big)
|
const bestImage = photo.c516x688 || photo.big || photo.c246x328 || photo.square || photo.tm
|
||||||
if (photo.c516x688) mediaFiles.push(photo.c516x688)
|
if (bestImage) {
|
||||||
if (photo.c246x328) mediaFiles.push(photo.c246x328)
|
mediaFiles.push(bestImage)
|
||||||
|
console.log(`WB API: Added image ${index + 1} for card ${card.nmID}:`, bestImage)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если нет photos, пытаемся сгенерировать fallback изображения
|
||||||
|
if (mediaFiles.length === 0) {
|
||||||
|
const vol = Math.floor(card.nmID / 100000)
|
||||||
|
const part = Math.floor(card.nmID / 1000)
|
||||||
|
const fallbackUrl = `https://basket-${String(vol).padStart(2, '0')}.wbbasket.ru/vol${vol}/part${part}/${card.nmID}/images/c246x328/1.webp`
|
||||||
|
mediaFiles.push(fallbackUrl)
|
||||||
|
console.log(`WB API: Added fallback image for card ${card.nmID}:`, fallbackUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`WB API: Final mediaFiles for card ${card.nmID}:`, mediaFiles)
|
||||||
|
|
||||||
// Заполняем размеры с ценами и количеством для совместимости
|
// Заполняем размеры с ценами и количеством для совместимости
|
||||||
const processedSizes = card.sizes.map(size => ({
|
const processedSizes = card.sizes.map(size => ({
|
||||||
...size,
|
...size,
|
||||||
@ -775,8 +898,14 @@ class WildberriesService {
|
|||||||
return this.formatDate(date)
|
return this.formatDate(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Статический метод для получения остатков с токеном
|
||||||
|
static async getStocks(apiKey: string): Promise<unknown[]> {
|
||||||
|
const service = new WildberriesService(apiKey)
|
||||||
|
return service.getStocks()
|
||||||
|
}
|
||||||
|
|
||||||
// Статический метод для получения складов с токеном
|
// Статический метод для получения складов с токеном
|
||||||
static async getWarehouses(apiKey: string): Promise<WildberriesWarehouse[]> {
|
static async getWarehouses(apiKey: string): Promise<Array<{ id: number; name: string; cargoType: number; deliveryType: number }>> {
|
||||||
const service = new WildberriesService(apiKey)
|
const service = new WildberriesService(apiKey)
|
||||||
return service.getWarehouses()
|
return service.getWarehouses()
|
||||||
}
|
}
|
||||||
@ -855,6 +984,367 @@ class WildberriesService {
|
|||||||
// Fallback на mediaFiles для старых данных
|
// Fallback на mediaFiles для старых данных
|
||||||
return card.mediaFiles || []
|
return card.mediaFiles || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Получение остатков товаров на складах
|
||||||
|
async getStocks(): Promise<unknown[]> {
|
||||||
|
try {
|
||||||
|
console.log('WB API: Getting stocks using marketplace API')
|
||||||
|
|
||||||
|
// 1. Сначала получаем список складов продавца
|
||||||
|
const warehouses = await this.getWarehouses()
|
||||||
|
console.log(`WB API: Got ${warehouses.length} warehouses`)
|
||||||
|
|
||||||
|
if (warehouses.length === 0) {
|
||||||
|
console.log('WB API: No warehouses found')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Получаем карточки товаров для получения SKU/баркодов
|
||||||
|
const cardsResponse = await this.getCards({ limit: 100 })
|
||||||
|
const cards = cardsResponse.cards
|
||||||
|
console.log(`WB API: Got ${cards.length} cards`)
|
||||||
|
console.log(`WB API: Sample card photos:`, cards[0]?.photos)
|
||||||
|
|
||||||
|
if (cards.length === 0) {
|
||||||
|
console.log('WB API: No cards found')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Собираем все SKU из карточек товаров
|
||||||
|
const allSkus: string[] = []
|
||||||
|
const cardSkuMap = new Map<string, WildberriesCard>()
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
if (card.sizes && card.sizes.length > 0) {
|
||||||
|
card.sizes.forEach(size => {
|
||||||
|
if (size.skus && size.skus.length > 0) {
|
||||||
|
size.skus.forEach(sku => {
|
||||||
|
if (sku) {
|
||||||
|
allSkus.push(sku)
|
||||||
|
cardSkuMap.set(sku, card)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`WB API: Collected ${allSkus.length} SKUs from cards`)
|
||||||
|
|
||||||
|
if (allSkus.length === 0) {
|
||||||
|
console.log('WB API: No SKUs found in cards')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Для каждого склада получаем остатки
|
||||||
|
const allStocks: unknown[] = []
|
||||||
|
|
||||||
|
for (const warehouse of warehouses) {
|
||||||
|
try {
|
||||||
|
const stocksUrl = `https://marketplace-api.wildberries.ru/api/v3/stocks/${warehouse.id}`
|
||||||
|
console.log(`WB API: Getting stocks for warehouse ${warehouse.id} (${warehouse.name})`)
|
||||||
|
|
||||||
|
// Разбиваем SKUs на порции по 1000 (лимит API)
|
||||||
|
const chunkSize = 1000
|
||||||
|
for (let i = 0; i < allSkus.length; i += chunkSize) {
|
||||||
|
const skuChunk = allSkus.slice(i, i + chunkSize)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stocksResponse = await this.makeRequest<{
|
||||||
|
stocks: Array<{
|
||||||
|
sku: string
|
||||||
|
amount: number
|
||||||
|
}>
|
||||||
|
}>(stocksUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ skus: skuChunk })
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`WB API: Got ${stocksResponse.stocks?.length || 0} stock records for warehouse ${warehouse.id}`)
|
||||||
|
|
||||||
|
// Преобразуем данные в нужный формат
|
||||||
|
if (stocksResponse.stocks) {
|
||||||
|
stocksResponse.stocks.forEach(stock => {
|
||||||
|
const card = cardSkuMap.get(stock.sku)
|
||||||
|
if (card) {
|
||||||
|
console.log(`WB API: Creating stock entry for card ${card.nmID}`)
|
||||||
|
console.log(`WB API: Card photos:`, card.photos)
|
||||||
|
console.log(`WB API: Card mediaFiles:`, card.mediaFiles)
|
||||||
|
|
||||||
|
allStocks.push({
|
||||||
|
nmId: card.nmID,
|
||||||
|
vendorCode: card.vendorCode,
|
||||||
|
title: card.title,
|
||||||
|
brand: card.brand,
|
||||||
|
subject: card.object || card.subjectName,
|
||||||
|
subjectName: card.subjectName,
|
||||||
|
category: card.subjectName,
|
||||||
|
description: card.description,
|
||||||
|
warehouseId: warehouse.id,
|
||||||
|
warehouseName: warehouse.name,
|
||||||
|
quantity: stock.amount,
|
||||||
|
quantityFull: stock.amount,
|
||||||
|
inWayToClient: 0, // Эти данные недоступны через marketplace API
|
||||||
|
inWayFromClient: 0,
|
||||||
|
price: 0, // Цены получаются отдельно
|
||||||
|
sku: stock.sku,
|
||||||
|
photos: card.photos || [],
|
||||||
|
mediaFiles: card.mediaFiles || [], // ЗДЕСЬ ДОЛЖНЫ БЫТЬ ОБРАБОТАННЫЕ ИЗОБРАЖЕНИЯ!
|
||||||
|
characteristics: card.characteristics || []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (chunkError) {
|
||||||
|
console.error(`WB API: Error getting stocks chunk for warehouse ${warehouse.id}:`, chunkError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (warehouseError) {
|
||||||
|
console.error(`WB API: Error getting stocks for warehouse ${warehouse.id}:`, warehouseError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Добавляем карточки, для которых не найдено остатков (показываем их с нулевыми остатками)
|
||||||
|
const stockedCardIds = new Set(allStocks.map(stock => (stock as Record<string, unknown>).nmId))
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
if (!stockedCardIds.has(card.nmID)) {
|
||||||
|
console.log(`WB API: Adding zero-stock entry for card ${card.nmID}`)
|
||||||
|
console.log(`WB API: Card photos:`, card.photos)
|
||||||
|
console.log(`WB API: Card mediaFiles:`, card.mediaFiles)
|
||||||
|
|
||||||
|
// Для каждого склада создаем запись с нулевыми остатками
|
||||||
|
warehouses.forEach(warehouse => {
|
||||||
|
allStocks.push({
|
||||||
|
nmId: card.nmID,
|
||||||
|
vendorCode: card.vendorCode,
|
||||||
|
title: card.title,
|
||||||
|
brand: card.brand,
|
||||||
|
subject: card.object || card.subjectName,
|
||||||
|
subjectName: card.subjectName,
|
||||||
|
category: card.subjectName,
|
||||||
|
description: card.description,
|
||||||
|
warehouseId: warehouse.id,
|
||||||
|
warehouseName: warehouse.name,
|
||||||
|
quantity: 0,
|
||||||
|
quantityFull: 0,
|
||||||
|
inWayToClient: 0,
|
||||||
|
inWayFromClient: 0,
|
||||||
|
price: 0,
|
||||||
|
sku: '',
|
||||||
|
photos: card.photos || [],
|
||||||
|
mediaFiles: card.mediaFiles || [], // ВАЖНО: ОБРАБОТАННЫЕ ИЗОБРАЖЕНИЯ!
|
||||||
|
characteristics: card.characteristics || []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`WB API: Total collected ${allStocks.length} stock records (including zero stocks)`)
|
||||||
|
return allStocks
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`WB API: Error getting stocks:`, error)
|
||||||
|
console.log('WB API: Returning empty stocks array due to API error')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метод для получения даты N дней назад
|
||||||
|
private getDateDaysAgo(days: number): string {
|
||||||
|
const date = new Date()
|
||||||
|
date.setDate(date.getDate() - days)
|
||||||
|
return date.toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Новый метод для получения данных по складам через Analytics API
|
||||||
|
async getStocksReportByOffices(params: {
|
||||||
|
nmIds?: number[]
|
||||||
|
subjectIds?: number[]
|
||||||
|
brandNames?: string[]
|
||||||
|
tagIds?: number[]
|
||||||
|
dateFrom?: string
|
||||||
|
dateTo?: string
|
||||||
|
stockType?: '' | 'wb' | 'mp'
|
||||||
|
} = {}): Promise<WBStock[]> {
|
||||||
|
try {
|
||||||
|
console.log('WB Analytics API: Getting stocks report by offices...')
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const dateFrom = params.dateFrom || today
|
||||||
|
const dateTo = params.dateTo || today
|
||||||
|
|
||||||
|
const requestBody: StocksReportOfficesRequest = {
|
||||||
|
nmIDs: params.nmIds,
|
||||||
|
subjectIDs: params.subjectIds,
|
||||||
|
brandNames: params.brandNames,
|
||||||
|
tagIDs: params.tagIds,
|
||||||
|
currentPeriod: {
|
||||||
|
start: dateFrom,
|
||||||
|
end: dateTo
|
||||||
|
},
|
||||||
|
stockType: params.stockType || '', // все склады
|
||||||
|
skipDeletedNm: true
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('WB Analytics API: Request parameters:')
|
||||||
|
console.log('- nmIDs:', params.nmIds)
|
||||||
|
console.log('- subjectIDs:', params.subjectIds)
|
||||||
|
console.log('- brandNames:', params.brandNames)
|
||||||
|
console.log('- tagIDs:', params.tagIds)
|
||||||
|
console.log('- currentPeriod:', { start: dateFrom, end: dateTo })
|
||||||
|
console.log('- stockType:', params.stockType || 'all')
|
||||||
|
console.log('- skipDeletedNm:', true)
|
||||||
|
|
||||||
|
console.log('WB Analytics API: Request body:', JSON.stringify(requestBody, null, 2))
|
||||||
|
|
||||||
|
// Используем Analytics API
|
||||||
|
const analyticsURL = 'https://seller-analytics-api.wildberries.ru'
|
||||||
|
const url = `${analyticsURL}/api/v2/stocks-report/offices`
|
||||||
|
|
||||||
|
const response = await this.makeRequest<StocksReportOfficesResponse>(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('WB Analytics API: Response:', JSON.stringify(response, null, 2))
|
||||||
|
|
||||||
|
console.log('WB Analytics API: Processing response data...')
|
||||||
|
|
||||||
|
// Преобразуем данные Analytics API в формат WBStock
|
||||||
|
const stocks: WBStock[] = []
|
||||||
|
|
||||||
|
if (response.data?.regions) {
|
||||||
|
console.log(`WB Analytics API: Found ${response.data.regions.length} regions`)
|
||||||
|
|
||||||
|
// Получаем карточки товаров и остатки для сопоставления
|
||||||
|
console.log('WB Analytics API: Loading cards and current stocks for matching...')
|
||||||
|
const [cards, currentStocks] = await Promise.all([
|
||||||
|
WildberriesService.getAllCards(this.apiKey).catch(() => []),
|
||||||
|
this.getStocks().catch(() => [])
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log(`WB Analytics API: Loaded ${cards.length} cards and ${currentStocks.length} stock records`)
|
||||||
|
|
||||||
|
const cardsMap = new Map(cards.map((card: WildberriesCard) => [card.nmID, card]))
|
||||||
|
|
||||||
|
// Создаем карту остатков по складам из текущих данных
|
||||||
|
const stocksByWarehouse = new Map<number, Record<string, unknown>[]>()
|
||||||
|
const typedCurrentStocks = currentStocks as Record<string, unknown>[]
|
||||||
|
typedCurrentStocks.forEach((stock: Record<string, unknown>) => {
|
||||||
|
const warehouseId = Number(stock.warehouseId || stock.warehouse) || 0
|
||||||
|
if (!stocksByWarehouse.has(warehouseId)) {
|
||||||
|
stocksByWarehouse.set(warehouseId, [])
|
||||||
|
}
|
||||||
|
stocksByWarehouse.get(warehouseId)!.push(stock)
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.regions.forEach(region => {
|
||||||
|
console.log(`WB Analytics API: Processing region "${region.regionName}" with ${region.offices.length} offices`)
|
||||||
|
|
||||||
|
region.offices.forEach(office => {
|
||||||
|
console.log(`WB Analytics API: Processing office "${office.officeName}" (ID: ${office.officeID})`)
|
||||||
|
console.log(`WB Analytics API: Office metrics:`, office.metrics)
|
||||||
|
|
||||||
|
// Получаем товары для этого склада WB
|
||||||
|
const warehouseStocks = stocksByWarehouse.get(office.officeID) || []
|
||||||
|
console.log(`WB Analytics API: Found ${warehouseStocks.length} stock records for warehouse ${office.officeID}`)
|
||||||
|
|
||||||
|
// Создаем записи для каждого товара на этом складе WB
|
||||||
|
// Если нет конкретных остатков, создаем на основе карточек товаров
|
||||||
|
if (warehouseStocks.length > 0) {
|
||||||
|
// Группируем по nmId
|
||||||
|
const stocksByNmId = new Map<number, Record<string, unknown>[]>()
|
||||||
|
warehouseStocks.forEach((stock: Record<string, unknown>) => {
|
||||||
|
const nmId = Number(stock.nmId) || 0
|
||||||
|
if (nmId > 0) {
|
||||||
|
if (!stocksByNmId.has(nmId)) {
|
||||||
|
stocksByNmId.set(nmId, [])
|
||||||
|
}
|
||||||
|
stocksByNmId.get(nmId)!.push(stock)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Создаем записи для каждого товара
|
||||||
|
stocksByNmId.forEach((stockItems, nmId) => {
|
||||||
|
const firstStock = stockItems[0]
|
||||||
|
const card = cardsMap.get(nmId)
|
||||||
|
|
||||||
|
const stock: WBStock = {
|
||||||
|
nmId,
|
||||||
|
vendorCode: String(firstStock.vendorCode || firstStock.supplierArticle || ''),
|
||||||
|
title: String(firstStock.title || firstStock.subject || card?.title || `Товар ${nmId}`),
|
||||||
|
brand: String(firstStock.brand || card?.brand || ''),
|
||||||
|
price: Number(firstStock.price || firstStock.Price) || 0,
|
||||||
|
stocks: [{
|
||||||
|
warehouseId: office.officeID,
|
||||||
|
warehouseName: office.officeName,
|
||||||
|
quantity: Number(firstStock.quantity) || 0,
|
||||||
|
quantityFull: Number(firstStock.quantityFull) || 0,
|
||||||
|
inWayToClient: office.metrics.toClientCount, // Берем из Analytics API
|
||||||
|
inWayFromClient: office.metrics.fromClientCount // Берем из Analytics API
|
||||||
|
}],
|
||||||
|
totalQuantity: Number(firstStock.quantity) || 0,
|
||||||
|
totalReserved: office.metrics.toClientCount,
|
||||||
|
photos: Array.isArray(firstStock.photos) ? firstStock.photos : (card?.photos || []),
|
||||||
|
mediaFiles: Array.isArray(firstStock.mediaFiles) ? firstStock.mediaFiles : [],
|
||||||
|
characteristics: Array.isArray(firstStock.characteristics) ? firstStock.characteristics : (card?.characteristics || []),
|
||||||
|
subjectName: String(firstStock.subjectName || firstStock.subject || card?.subjectName || ''),
|
||||||
|
description: String(firstStock.description || card?.description || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
stocks.push(stock)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.log(`WB Analytics API: No stock records found for warehouse ${office.officeID}, creating entries for each product`)
|
||||||
|
|
||||||
|
// Создаем записи для каждого товара на этом складе WB
|
||||||
|
// Даже если нет точных остатков, показываем движение товаров
|
||||||
|
cardsMap.forEach((card, nmId) => {
|
||||||
|
const stock: WBStock = {
|
||||||
|
nmId,
|
||||||
|
vendorCode: String(card.vendorCode || ''),
|
||||||
|
title: String(card.title || `Товар ${nmId}`),
|
||||||
|
brand: String(card.brand || ''),
|
||||||
|
price: 0, // У карточки нет цены, используем 0
|
||||||
|
stocks: [{
|
||||||
|
warehouseId: office.officeID,
|
||||||
|
warehouseName: office.officeName,
|
||||||
|
quantity: office.metrics.stockCount, // Общее количество на складе
|
||||||
|
quantityFull: office.metrics.stockCount,
|
||||||
|
inWayToClient: office.metrics.toClientCount, // К клиенту
|
||||||
|
inWayFromClient: office.metrics.fromClientCount // От клиента
|
||||||
|
}],
|
||||||
|
totalQuantity: office.metrics.stockCount,
|
||||||
|
totalReserved: office.metrics.toClientCount,
|
||||||
|
photos: Array.isArray(card.photos) ? card.photos : [],
|
||||||
|
mediaFiles: Array.isArray(card.mediaFiles) ? card.mediaFiles : [],
|
||||||
|
characteristics: Array.isArray(card.characteristics) ? card.characteristics : [],
|
||||||
|
subjectName: String(card.subjectName || region.regionName),
|
||||||
|
description: String(card.description || `Регион: ${region.regionName}, Склад: ${office.officeName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
stocks.push(stock)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.log('WB Analytics API: No regions data in response')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`WB Analytics API: Processed ${stocks.length} stock records`)
|
||||||
|
return stocks
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WB Analytics API: Error getting stocks report:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { WildberriesService }
|
export { WildberriesService }
|
Reference in New Issue
Block a user