Обновлен README.md с новыми возможностями платформы Sfera V для управления бизнесом, добавлен раздел о складе Wildberries для селлеров. В компоненте Sidebar добавлена кнопка для перехода к складу ВБ, доступная только для пользователей с типом организации "SELLER". В классе WildberriesService реализованы новые методы для получения остатков товаров и интеграции с API Wildberries, включая обработку ошибок и кэширование данных.

This commit is contained in:
Bivekich
2025-07-23 18:02:20 +03:00
parent f478754bef
commit ff81d603a6
5 changed files with 1278 additions and 118 deletions

View File

@ -11,6 +11,86 @@ interface WildberriesWarehousesResponse {
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 {
nmID: number
imtID?: number
@ -266,10 +346,15 @@ class WildberriesService {
}
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, {
...options,
headers: {
'Authorization': this.apiKey,
...authHeader,
'Content-Type': 'application/json',
...options.headers,
},
@ -393,12 +478,35 @@ class WildberriesService {
}
}
// Получение списка складов
async getWarehouses(): Promise<WildberriesWarehouse[]> {
const url = `${this.baseURL}/api/v2/warehouses`
console.log(`WB API: Getting warehouses from ${url}`)
const response = await this.makeRequest<WildberriesWarehouse[]>(url)
return response || []
// Получение списка складов
async getWarehouses(): Promise<Array<{ id: number; name: string; cargoType: number; deliveryType: number }>> {
try {
// Используем правильный API endpoint для получения складов продавца
const url = `https://marketplace-api.wildberries.ru/api/v3/warehouses`
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
const mediaFiles: string[] = []
console.log(`WB API: Processing card ${card.nmID}, photos:`, card.photos)
if (card.photos && card.photos.length > 0) {
card.photos.forEach(photo => {
// Добавляем разные размеры изображений, приоритет большим размерам
if (photo.big) mediaFiles.push(photo.big)
if (photo.c516x688) mediaFiles.push(photo.c516x688)
if (photo.c246x328) mediaFiles.push(photo.c246x328)
card.photos.forEach((photo, index) => {
// Для каждого фото берем лучший доступный размер
const bestImage = photo.c516x688 || photo.big || photo.c246x328 || photo.square || photo.tm
if (bestImage) {
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 => ({
...size,
@ -775,8 +898,14 @@ class WildberriesService {
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)
return service.getWarehouses()
}
@ -855,6 +984,367 @@ class WildberriesService {
// Fallback на 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 }