Добавлены новые зависимости для работы с графиками и статистикой: интегрирован пакет recharts для визуализации данных. Обновлены компоненты бизнес-демо и сайдбара, добавлены новые функции для отображения информации о поставках и статистике. Улучшена структура кода и взаимодействие с пользователем. Обновлены GraphQL резолверы для получения статистики Wildberries.

This commit is contained in:
Bivekich
2025-07-22 13:29:15 +03:00
parent 20c27a2fa2
commit a62a09faca
13 changed files with 2388 additions and 122 deletions

View File

@ -61,143 +61,388 @@ interface WildberriesCardFilter {
}
}
export class WildberriesService {
private static contentUrl = 'https://content-api.wildberries.ru'
private static publicUrl = 'https://public-api.wildberries.ru'
private static supplierUrl = 'https://suppliers-api.wildberries.ru'
export interface WBSalesData {
date: string
saleID: string
gNumber: string
supplierArticle: string
techSize: string
barcode: string
totalPrice: number
discountPercent: number
isSupply: boolean
isRealization: boolean
promoCodeDiscount: number
warehouseName: string
countryName: string
oblastOkrugName: string
regionName: string
incomeID: number
isCancel: boolean
cancelDate: string
orderType: string
sticker: string
srp: number
}
export interface WBOrdersData {
date: string
lastChangeDate: string
supplierArticle: string
techSize: string
barcode: string
totalPrice: number
discountPercent: number
warehouseName: string
oblast: string
incomeID: number
odid: number
nmId: number
subject: string
category: string
brand: string
isCancel: boolean
cancel_dt: string
}
export interface WBAdvertData {
advertId: number
name: string
status: number
type: number
createTime: string
changeTime: string
startTime: string
endTime: string
dailyBudget: number
budget: number
}
export interface WBAdvertStatsRequest {
id?: number
dates?: string[]
interval?: {
begin: string
end: string
}
}
export interface WBAdvertStatsResponse {
dates: string[]
views: number
clicks: number
ctr: number
cpc: number
sum: number
atbs: number
orders: number
cr: number
shks: number
sum_price: number
days: Array<{
date: string
views: number
clicks: number
ctr: number
cpc: number
sum: number
atbs: number
orders: number
cr: number
shks: number
sum_price: number
}>
advertId: number
}
export interface WBStatisticsData {
date: string
sales: number
orders: number
advertising: number
refusals: number
returns: number
revenue: number
buyoutPercentage: number
}
class WildberriesService {
private apiKey: string
private baseURL = 'https://statistics-api.wildberries.ru'
private advertURL = 'https://advert-api.wildberries.ru'
constructor(apiKey: string) {
this.apiKey = apiKey
}
private async makeRequest<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
...options,
headers: {
'Authorization': this.apiKey,
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
throw new Error(`WB API Error: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<T>
}
// Получение данных о продажах
async getSales(dateFrom: string, flag = 0): Promise<WBSalesData[]> {
const url = `${this.baseURL}/api/v1/supplier/sales?dateFrom=${dateFrom}&flag=${flag}`
return this.makeRequest<WBSalesData[]>(url)
}
// Получение данных о заказах
async getOrders(dateFrom: string, flag = 0): Promise<WBOrdersData[]> {
const url = `${this.baseURL}/api/v1/supplier/orders?dateFrom=${dateFrom}&flag=${flag}`
return this.makeRequest<WBOrdersData[]>(url)
}
// Получение рекламных кампаний
async getAdverts(status?: number, type?: number, limit = 100, offset = 0): Promise<WBAdvertData[]> {
let url = `${this.advertURL}/adv/v0/adverts?limit=${limit}&offset=${offset}`
if (status) url += `&status=${status}`
if (type) url += `&type=${type}`
return this.makeRequest<WBAdvertData[]>(url)
}
// Получение статистики всех рекламных кампаний (v2)
async getAdvertStats(dateFrom: string, dateTo: string): Promise<WBAdvertStatsResponse[]> {
const url = `${this.advertURL}/adv/v2/fullstats`
// Попробуем через interval - это может работать лучше
const request: WBAdvertStatsRequest[] = [{
interval: {
begin: dateFrom,
end: dateTo
}
}]
console.log(`WB API: Requesting campaign stats with interval: ${dateFrom} to ${dateTo}`)
console.log(`WB API: Request body:`, JSON.stringify(request, null, 2))
/**
* Получение карточек товаров через Content API v2
*/
static async getCards(apiKey: string, filter?: WildberriesCardFilter): Promise<WildberriesCard[]> {
try {
console.log('Calling WB Content API v2 with filter:', filter)
const response = await fetch(`${this.contentUrl}/content/v2/get/cards/list`, {
const response = await this.makeRequest<WBAdvertStatsResponse[]>(url, {
method: 'POST',
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(filter || {
settings: {
cursor: {
limit: 100
},
filter: {
withPhoto: -1
}
}
})
body: JSON.stringify(request)
})
console.log(`WB API: Advert response:`, JSON.stringify(response, null, 2))
return response
} catch (error) {
console.error(`WB API: Advert stats error:`, error)
throw error
}
}
console.log(`${this.contentUrl}/content/v2/get/cards/list`, response.status, response.statusText)
// Получение детального отчета по периоду
async getDetailReport(dateFrom: string, dateTo: string, limit = 10000, rrdid = 0): Promise<Record<string, unknown>[]> {
const url = `${this.baseURL}/api/v1/supplier/reportDetailByPeriod?dateFrom=${dateFrom}&dateTo=${dateTo}&limit=${limit}&rrdid=${rrdid}`
return this.makeRequest<Record<string, unknown>[]>(url)
}
if (!response.ok) {
const errorText = await response.text()
let errorData
try {
errorData = JSON.parse(errorText)
} catch {
errorData = { message: errorText }
}
// Агрегированная статистика для дашборда
async getStatistics(dateFrom: string, dateTo: string): Promise<WBStatisticsData[]> {
try {
console.log(`WB API: Getting statistics from ${dateFrom} to ${dateTo}`)
// Получаем продажи и заказы
const [salesData, ordersData] = await Promise.all([
this.getSales(dateFrom, 0), // flag=0 для получения данных за период от dateFrom до сегодня
this.getOrders(dateFrom, 0)
])
console.log(`WB API: Got ${salesData.length} sales, ${ordersData.length} orders`)
// Получаем статистику рекламы напрямую через /adv/v2/fullstats
let advertStatsData: WBAdvertStatsResponse[] = []
try {
console.log(`WB API: Getting campaign stats for interval: ${dateFrom} to ${dateTo}`)
console.log('WB API Error Response:', errorData)
throw new Error(`WB API Error: ${response.status} - ${response.statusText}`)
advertStatsData = await this.getAdvertStats(dateFrom, dateTo)
console.log(`WB API: Got advertising stats for ${advertStatsData.length} campaigns`)
// Логируем детали рекламных затрат
advertStatsData.forEach(stat => {
const totalSpend = stat.days?.reduce((sum, day) => sum + (day.sum || 0), 0) || stat.sum || 0
console.log(`WB API: Campaign ${stat.advertId} spent ${totalSpend} rubles over ${stat.days?.length || 0} days`)
})
} catch (error) {
console.warn('WB API: Failed to get advertising stats:', error)
}
const data: WildberriesCardsResponse = await response.json()
return data.cards || []
} catch (error) {
console.error('Error fetching WB cards:', error)
throw new Error('Ошибка получения карточек товаров')
}
}
// Группируем данные по датам
const statsMap = new Map<string, WBStatisticsData>()
/**
* Поиск карточек товаров
*/
static async searchCards(apiKey: string, searchTerm: string, limit = 50): Promise<WildberriesCard[]> {
const filter: WildberriesCardFilter = {
settings: {
cursor: {
limit
},
filter: {
textSearch: searchTerm,
withPhoto: -1
// Обрабатываем продажи
salesData.forEach(sale => {
const date = sale.date.split('T')[0] // Берем только дату без времени
// Строгая фильтрация по диапазону дат
if (date < dateFrom || date > dateTo) return
if (!statsMap.has(date)) {
statsMap.set(date, {
date,
sales: 0,
orders: 0,
advertising: 0,
refusals: 0,
returns: 0,
revenue: 0,
buyoutPercentage: 0
})
}
}
}
return this.getCards(apiKey, filter)
}
/**
* Получение всех карточек товаров с пагинацией
*/
static async getAllCards(apiKey: string, limit = 100): Promise<WildberriesCard[]> {
const filter: WildberriesCardFilter = {
settings: {
cursor: {
limit
},
filter: {
withPhoto: -1
}
}
}
return this.getCards(apiKey, filter)
}
/**
* Получение складов WB
*/
static async getWarehouses(apiKey: string): Promise<WildberriesWarehouse[]> {
try {
const response = await fetch(`${this.supplierUrl}/api/v3/warehouses`, {
headers: {
'Authorization': apiKey,
const stats = statsMap.get(date)!
if (!sale.isCancel) {
stats.sales += 1
stats.revenue += sale.totalPrice * (1 - sale.discountPercent / 100)
} else {
stats.returns += 1
}
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
// Обрабатываем заказы
ordersData.forEach(order => {
const date = order.date.split('T')[0]
// Строгая фильтрация по диапазону дат
if (date < dateFrom || date > dateTo) return
if (!statsMap.has(date)) {
statsMap.set(date, {
date,
sales: 0,
orders: 0,
advertising: 0,
refusals: 0,
returns: 0,
revenue: 0,
buyoutPercentage: 0
})
}
const data: WildberriesWarehousesResponse = await response.json()
return data.data || []
} catch (error) {
console.error('Error fetching warehouses:', error)
return []
}
}
/**
* Проверка валидности API ключа
*/
static async validateApiKey(apiKey: string): Promise<boolean> {
try {
const response = await fetch(`${this.contentUrl}/content/v2/get/cards/list`, {
method: 'POST',
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
settings: {
cursor: {
limit: 1
}
}
})
const stats = statsMap.get(date)!
if (!order.isCancel) {
stats.orders += 1
} else {
stats.refusals += 1
}
})
return response.ok
// Обрабатываем данные рекламы
advertStatsData.forEach(advertStat => {
console.log(`WB API: Processing advert ${advertStat.advertId}, total sum: ${advertStat.sum}`)
// Обрабатываем статистику по дням для каждой кампании
if (advertStat.days && advertStat.days.length > 0) {
advertStat.days.forEach(day => {
const date = day.date
console.log(`WB API: Day ${date} - spent ${day.sum} rubles (campaign ${advertStat.advertId})`)
// Строгая фильтрация по диапазону дат
if (date < dateFrom || date > dateTo) {
console.log(`WB API: Skipping ${date} - outside range ${dateFrom} to ${dateTo}`)
return
}
if (!statsMap.has(date)) {
statsMap.set(date, {
date,
sales: 0,
orders: 0,
advertising: 0,
refusals: 0,
returns: 0,
revenue: 0,
buyoutPercentage: 0
})
}
const stats = statsMap.get(date)!
const adSpend = day.sum || 0
stats.advertising += adSpend
console.log(`WB API: Added ${adSpend} rubles to ${date}, total now: ${stats.advertising}`)
})
} else {
console.log(`WB API: No daily data for campaign ${advertStat.advertId}`)
}
})
// Вычисляем процент выкупов
statsMap.forEach(stats => {
if (stats.orders > 0) {
// Ограничиваем процент выкупов до 100% максимум
const percentage = Math.min(100, Math.round((stats.sales / stats.orders) * 100))
stats.buyoutPercentage = percentage
}
})
// Получаем данные по рекламе (это более сложно, так как нужна отдельная статистика)
// Пока используем заглушку, в реальности нужно вызвать отдельный API
return Array.from(statsMap.values()).sort((a, b) => a.date.localeCompare(b.date))
} catch (error) {
console.error('Error validating API key:', error)
return false
console.error('Error fetching WB statistics:', error)
throw error
}
}
}
// Получение статистики рекламы (заглушка)
async getAdvertStatistics(dateFrom: string, dateTo: string): Promise<{ date: string; spend: number }[]> {
// В реальности здесь нужно вызвать соответствующий API для статистики рекламы
// Пока возвращаем заглушку
return []
}
// Генерирует массив дат между dateFrom и dateTo
private generateDateRange(dateFrom: string, dateTo: string): string[] {
const dates: string[] = []
const start = new Date(dateFrom)
const end = new Date(dateTo)
const current = new Date(start)
while (current <= end) {
dates.push(WildberriesService.formatDate(current))
current.setDate(current.getDate() + 1)
}
return dates
}
// Форматирование даты для WB API (RFC3339)
static formatDate(date: Date): string {
return date.toISOString().split('T')[0] // YYYY-MM-DD
}
// Получение даты период назад
static getDatePeriodAgo(period: 'week' | 'month' | 'quarter'): string {
const now = new Date()
const date = new Date(now)
switch (period) {
case 'week':
date.setDate(date.getDate() - 7)
break
case 'month':
date.setMonth(date.getMonth() - 1)
break
case 'quarter':
date.setMonth(date.getMonth() - 3)
break
}
return this.formatDate(date)
}
}
export { WildberriesService }