1266 lines
43 KiB
TypeScript
1266 lines
43 KiB
TypeScript
interface WildberriesWarehouse {
|
||
id: number
|
||
name: string
|
||
address: string
|
||
cargoType: number
|
||
latitude: number
|
||
longitude: number
|
||
}
|
||
|
||
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
|
||
nmUUID?: string
|
||
subjectID?: number
|
||
subjectName?: string
|
||
vendorCode: string
|
||
brand: string
|
||
title: string
|
||
description: string
|
||
needKiz?: boolean
|
||
photos?: Array<{
|
||
big: string
|
||
c246x328: string
|
||
c516x688: string
|
||
square: string
|
||
tm: string
|
||
}>
|
||
video?: string
|
||
dimensions?: {
|
||
length: number
|
||
width: number
|
||
height: number
|
||
weightBrutto: number
|
||
isValid: boolean
|
||
}
|
||
characteristics?: Array<{
|
||
id: number
|
||
name: string
|
||
value: string[]
|
||
}>
|
||
sizes: Array<{
|
||
chrtID: number
|
||
techSize: string
|
||
skus?: string[]
|
||
// Legacy fields for backward compatibility
|
||
wbSize: string
|
||
price: number
|
||
discountedPrice: number
|
||
quantity: number
|
||
}>
|
||
tags?: Array<{
|
||
id: number
|
||
name: string
|
||
color: string
|
||
}>
|
||
createdAt?: string
|
||
updatedAt?: string
|
||
// Legacy fields for backward compatibility
|
||
mediaFiles: string[]
|
||
object?: string
|
||
parent?: string
|
||
countryProduction?: string
|
||
supplierVendorCode?: string
|
||
}
|
||
|
||
interface WildberriesCardsResponse {
|
||
cursor: {
|
||
total: number
|
||
updatedAt: string
|
||
limit: number
|
||
nmID: number
|
||
}
|
||
cards: WildberriesCard[]
|
||
}
|
||
|
||
interface WildberriesCardFilter {
|
||
settings?: {
|
||
cursor?: {
|
||
limit?: number
|
||
nmID?: number
|
||
updatedAt?: string
|
||
}
|
||
filter?: {
|
||
textSearch?: string
|
||
withPhoto?: number
|
||
objectIDs?: number[]
|
||
tagIDs?: number[]
|
||
brandIDs?: number[]
|
||
colorIDs?: number[]
|
||
sizeIDs?: number[]
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
// Интерфейсы для API /adv/v1/promotion/count
|
||
export interface WBCampaignListItem {
|
||
advertId: number
|
||
changeTime: string // date-time
|
||
}
|
||
|
||
export interface WBAdvertGroup {
|
||
type: number
|
||
status: number
|
||
count: number
|
||
advert_list: WBCampaignListItem[]
|
||
}
|
||
|
||
export interface WBCampaignsListResponse {
|
||
adverts: WBAdvertGroup[] | null
|
||
all: number
|
||
}
|
||
|
||
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
|
||
}>
|
||
boosterStats: 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
|
||
}
|
||
|
||
// Новые интерфейсы для метода getCampaignStats
|
||
export interface WBCampaignStatsRequestWithDate {
|
||
id: number
|
||
dates: string[]
|
||
}
|
||
|
||
export interface WBCampaignStatsRequestWithInterval {
|
||
id: number
|
||
interval: {
|
||
begin: string
|
||
end: string
|
||
}
|
||
}
|
||
|
||
export interface WBCampaignStatsRequestWithCampaignID {
|
||
id: number
|
||
}
|
||
|
||
export type WBCampaignStatsRequest =
|
||
| WBCampaignStatsRequestWithDate
|
||
| WBCampaignStatsRequestWithInterval
|
||
| WBCampaignStatsRequestWithCampaignID
|
||
|
||
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'
|
||
private contentURL = 'https://content-api.wildberries.ru'
|
||
|
||
constructor(apiKey: string) {
|
||
this.apiKey = apiKey
|
||
}
|
||
|
||
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: {
|
||
...authHeader,
|
||
'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 getCampaignsList(): Promise<WBCampaignsListResponse> {
|
||
const url = `${this.advertURL}/adv/v1/promotion/count`
|
||
console.log(`WB API: Getting campaigns list from ${url}`)
|
||
return this.makeRequest<WBCampaignsListResponse>(url)
|
||
}
|
||
|
||
// Получение кампаний за определенный период по changeTime
|
||
async getCampaignsForPeriod(dateFrom: string, dateTo: string): Promise<number[]> {
|
||
const campaignsList = await this.getCampaignsList()
|
||
const fromDate = new Date(dateFrom)
|
||
const toDate = new Date(dateTo)
|
||
|
||
const campaignIds: number[] = []
|
||
|
||
if (campaignsList.adverts) {
|
||
for (const advertGroup of campaignsList.adverts) {
|
||
if (advertGroup.advert_list) {
|
||
for (const campaign of advertGroup.advert_list) {
|
||
const changeTime = new Date(campaign.changeTime)
|
||
// Проверяем что кампания была изменена в нужном периоде
|
||
if (changeTime >= fromDate && changeTime <= toDate) {
|
||
campaignIds.push(campaign.advertId)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(`WB API: Found ${campaignIds.length} campaigns for period ${dateFrom} - ${dateTo}`)
|
||
return campaignIds
|
||
}
|
||
|
||
// Старый метод для совместимости (используем новый API)
|
||
async getAdverts(status?: number, type?: number, limit = 100, offset = 0): Promise<WBAdvertData[]> {
|
||
const campaignsList = await this.getCampaignsList()
|
||
const campaigns: WBAdvertData[] = []
|
||
|
||
if (campaignsList.adverts) {
|
||
for (const advertGroup of campaignsList.adverts) {
|
||
// Фильтрация по статусу и типу если указаны
|
||
if (status && advertGroup.status !== status) continue
|
||
if (type && advertGroup.type !== type) continue
|
||
|
||
if (advertGroup.advert_list) {
|
||
for (const campaign of advertGroup.advert_list) {
|
||
campaigns.push({
|
||
advertId: campaign.advertId,
|
||
type: advertGroup.type,
|
||
status: advertGroup.status,
|
||
name: `Campaign ${campaign.advertId}`,
|
||
endTime: campaign.changeTime,
|
||
createTime: campaign.changeTime,
|
||
changeTime: campaign.changeTime,
|
||
startTime: campaign.changeTime, // Используем changeTime как заглушку
|
||
dailyBudget: 0, // Неизвестно из этого API
|
||
budget: 0 // Неизвестно из этого API
|
||
})
|
||
|
||
// Применяем лимит
|
||
if (campaigns.length >= limit) break
|
||
}
|
||
}
|
||
if (campaigns.length >= limit) break
|
||
}
|
||
}
|
||
|
||
return campaigns.slice(offset, offset + limit)
|
||
}
|
||
|
||
// Получение статистики конкретных кампаний (новый метод)
|
||
async getCampaignStats(requests: WBCampaignStatsRequest[]): Promise<WBAdvertStatsResponse[]> {
|
||
if (!requests || requests.length === 0) {
|
||
throw new Error('Requests array cannot be empty')
|
||
}
|
||
|
||
if (requests.length > 100) {
|
||
throw new Error('Maximum 100 campaigns can be requested at once')
|
||
}
|
||
|
||
const url = `${this.advertURL}/adv/v2/fullstats`
|
||
|
||
console.log(`WB API: Requesting campaign stats for ${requests.length} campaigns`)
|
||
console.log(`WB API: Request body:`, JSON.stringify(requests, null, 2))
|
||
|
||
try {
|
||
const response = await this.makeRequest<WBAdvertStatsResponse[]>(url, {
|
||
method: 'POST',
|
||
body: JSON.stringify(requests)
|
||
})
|
||
|
||
console.log(`WB API: Campaign stats response:`, JSON.stringify(response, null, 2))
|
||
return response
|
||
} catch (error) {
|
||
console.error(`WB API: Campaign stats error:`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// Получение списка складов
|
||
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 []
|
||
}
|
||
}
|
||
|
||
// Получение карточек товаров
|
||
async getCards(options: { limit?: number; cursor?: { updatedAt?: string; nmID?: number } } = {}): Promise<WildberriesCardsResponse> {
|
||
const { limit = 100, cursor } = options
|
||
const url = `${this.contentURL}/content/v2/get/cards/list`
|
||
|
||
const body = {
|
||
settings: {
|
||
cursor: {
|
||
limit,
|
||
...(cursor?.updatedAt && { updatedAt: cursor.updatedAt }),
|
||
...(cursor?.nmID && { nmID: cursor.nmID })
|
||
},
|
||
filter: {
|
||
withPhoto: -1
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(`WB API: Getting cards from ${url}`, body)
|
||
try {
|
||
const response = await this.makeRequest<WildberriesCardsResponse>(url, {
|
||
method: 'POST',
|
||
body: JSON.stringify(body)
|
||
})
|
||
|
||
// Преобразуем карточки для обратной совместимости
|
||
const processedCards = response.cards.map(this.processCard)
|
||
|
||
return {
|
||
...response,
|
||
cards: processedCards
|
||
}
|
||
} catch (error) {
|
||
console.error(`WB API: Error getting cards:`, error)
|
||
return { cards: [], cursor: { total: 0, updatedAt: '', limit: 0, nmID: 0 } }
|
||
}
|
||
}
|
||
|
||
// Обработка карточки для обратной совместимости
|
||
private processCard(card: WildberriesCard): WildberriesCard {
|
||
// Создаем массив 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, 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,
|
||
wbSize: size.wbSize || size.techSize || '',
|
||
price: size.price || 0,
|
||
discountedPrice: size.discountedPrice || size.price || 0,
|
||
quantity: size.quantity || 0
|
||
}))
|
||
|
||
return {
|
||
...card,
|
||
// Добавляем mediaFiles для обратной совместимости
|
||
mediaFiles,
|
||
// Заполняем legacy поля если они отсутствуют
|
||
object: card.object || card.subjectName || '',
|
||
parent: card.parent || '',
|
||
countryProduction: card.countryProduction || '',
|
||
supplierVendorCode: card.supplierVendorCode || card.vendorCode,
|
||
// Обработанные размеры
|
||
sizes: processedSizes
|
||
}
|
||
}
|
||
|
||
// Поиск карточек товаров
|
||
async searchCards(searchTerm: string, limit = 100): Promise<WildberriesCard[]> {
|
||
// Сначала получаем все карточки
|
||
const response = await this.getCards({ limit })
|
||
|
||
// Фильтруем результаты по поисковому запросу
|
||
const filteredCards = response.cards.filter((card: WildberriesCard) => {
|
||
const searchLower = searchTerm.toLowerCase()
|
||
return (
|
||
card.vendorCode?.toLowerCase().includes(searchLower) ||
|
||
card.object?.toLowerCase().includes(searchLower) ||
|
||
card.brand?.toLowerCase().includes(searchLower) ||
|
||
card.title?.toLowerCase().includes(searchLower)
|
||
)
|
||
})
|
||
|
||
console.log(`WB API: Search "${searchTerm}" found ${filteredCards.length} cards`)
|
||
return filteredCards
|
||
}
|
||
|
||
// Получение статистики всех рекламных кампаний (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))
|
||
|
||
try {
|
||
const response = await this.makeRequest<WBAdvertStatsResponse[]>(url, {
|
||
method: 'POST',
|
||
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
|
||
}
|
||
}
|
||
|
||
// Получение детального отчета по периоду
|
||
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)
|
||
}
|
||
|
||
// Агрегированная статистика для дашборда
|
||
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`)
|
||
|
||
// Получаем статистику рекламы через правильный API
|
||
let advertStatsData: WBAdvertStatsResponse[] = []
|
||
try {
|
||
console.log(`WB API: Getting campaign stats for interval: ${dateFrom} to ${dateTo}`)
|
||
|
||
try {
|
||
// Получаем ID кампаний, которые были изменены в указанном периоде
|
||
const campaignIds = await this.getCampaignsForPeriod(dateFrom, dateTo)
|
||
|
||
if (campaignIds.length > 0) {
|
||
// Создаем запросы для /adv/v2/fullstats с интервалом дат
|
||
const campaignRequests: WBCampaignStatsRequest[] = campaignIds.map(id => ({
|
||
id,
|
||
interval: {
|
||
begin: dateFrom,
|
||
end: dateTo
|
||
}
|
||
}))
|
||
|
||
// Получаем статистику кампаний
|
||
advertStatsData = await this.getCampaignStats(campaignRequests)
|
||
console.log(`WB API: Got advertising stats for ${advertStatsData.length} campaigns`)
|
||
} else {
|
||
console.log(`WB API: No campaigns found for the specified period`)
|
||
}
|
||
} catch (error) {
|
||
console.error(`WB API: Failed to get campaign stats:`, error)
|
||
console.log(`WB API: Skipping advertising stats due to API error`)
|
||
}
|
||
|
||
console.log(`WB API: Got advertising stats for ${advertStatsData.length} campaigns total`)
|
||
|
||
// Логируем детали рекламных затрат
|
||
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 statsMap = new Map<string, WBStatisticsData>()
|
||
|
||
// Обрабатываем продажи
|
||
console.log(`WB API: Processing ${salesData.length} sales records`)
|
||
salesData.forEach(sale => {
|
||
const originalDate = sale.date
|
||
const date = sale.date.split('T')[0] // Берем только дату без времени
|
||
|
||
console.log(`WB API: Processing sale - original date: ${originalDate}, normalized: ${date}`)
|
||
|
||
// Строгая фильтрация по диапазону дат
|
||
if (date < dateFrom || date > dateTo) {
|
||
console.log(`WB API: Skipping sale ${date} - outside range ${dateFrom} to ${dateTo}`)
|
||
return
|
||
}
|
||
|
||
if (!statsMap.has(date)) {
|
||
console.log(`WB API: Creating new stats entry for date ${date}`)
|
||
statsMap.set(date, {
|
||
date,
|
||
sales: 0,
|
||
orders: 0,
|
||
advertising: 0,
|
||
refusals: 0,
|
||
returns: 0,
|
||
revenue: 0,
|
||
buyoutPercentage: 0
|
||
})
|
||
}
|
||
|
||
const stats = statsMap.get(date)!
|
||
if (!sale.isCancel) {
|
||
stats.sales += 1
|
||
stats.revenue += sale.totalPrice * (1 - sale.discountPercent / 100)
|
||
console.log(`WB API: Added sale to ${date}, total sales now: ${stats.sales}`)
|
||
} else {
|
||
stats.returns += 1
|
||
console.log(`WB API: Added return to ${date}, total returns now: ${stats.returns}`)
|
||
}
|
||
})
|
||
|
||
// Обрабатываем заказы
|
||
console.log(`WB API: Processing ${ordersData.length} orders records`)
|
||
ordersData.forEach(order => {
|
||
const originalDate = order.date
|
||
const date = order.date.split('T')[0]
|
||
|
||
console.log(`WB API: Processing order - original date: ${originalDate}, normalized: ${date}`)
|
||
|
||
// Строгая фильтрация по диапазону дат
|
||
if (date < dateFrom || date > dateTo) {
|
||
console.log(`WB API: Skipping order ${date} - outside range ${dateFrom} to ${dateTo}`)
|
||
return
|
||
}
|
||
|
||
if (!statsMap.has(date)) {
|
||
console.log(`WB API: Creating new stats entry for date ${date} (from orders)`)
|
||
statsMap.set(date, {
|
||
date,
|
||
sales: 0,
|
||
orders: 0,
|
||
advertising: 0,
|
||
refusals: 0,
|
||
returns: 0,
|
||
revenue: 0,
|
||
buyoutPercentage: 0
|
||
})
|
||
}
|
||
|
||
const stats = statsMap.get(date)!
|
||
if (!order.isCancel) {
|
||
stats.orders += 1
|
||
console.log(`WB API: Added order to ${date}, total orders now: ${stats.orders}`)
|
||
} else {
|
||
stats.refusals += 1
|
||
console.log(`WB API: Added refusal to ${date}, total refusals now: ${stats.refusals}`)
|
||
}
|
||
})
|
||
|
||
// Обрабатываем данные рекламы
|
||
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
|
||
|
||
const finalResults = Array.from(statsMap.values()).sort((a, b) => a.date.localeCompare(b.date))
|
||
console.log(`WB API: Final aggregated results:`, finalResults)
|
||
console.log(`WB API: Unique dates count: ${finalResults.length}`)
|
||
|
||
return finalResults
|
||
} catch (error) {
|
||
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)
|
||
}
|
||
|
||
// Статический метод для получения остатков с токеном
|
||
static async getStocks(apiKey: string): Promise<unknown[]> {
|
||
const service = new WildberriesService(apiKey)
|
||
return service.getStocks()
|
||
}
|
||
|
||
// Статический метод для получения складов с токеном
|
||
static async getWarehouses(apiKey: string): Promise<Array<{ id: number; name: string; cargoType: number; deliveryType: number }>> {
|
||
const service = new WildberriesService(apiKey)
|
||
return service.getWarehouses()
|
||
}
|
||
|
||
// Получение всех карточек с пагинацией
|
||
async getAllCardsWithPagination(maxCards = 1000): Promise<WildberriesCard[]> {
|
||
const allCards: WildberriesCard[] = []
|
||
let cursor: { updatedAt?: string; nmID?: number } | undefined
|
||
|
||
while (allCards.length < maxCards) {
|
||
const response = await this.getCards({
|
||
limit: Math.min(100, maxCards - allCards.length),
|
||
cursor
|
||
})
|
||
|
||
if (!response.cards || response.cards.length === 0) {
|
||
break
|
||
}
|
||
|
||
allCards.push(...response.cards)
|
||
|
||
// Если получили меньше чем запрашивали, значит это последняя страница
|
||
if (response.cards.length < 100) {
|
||
break
|
||
}
|
||
|
||
// Обновляем курсор для следующего запроса
|
||
const lastCard = response.cards[response.cards.length - 1]
|
||
cursor = {
|
||
updatedAt: response.cursor.updatedAt,
|
||
nmID: lastCard.nmID
|
||
}
|
||
}
|
||
|
||
return allCards
|
||
}
|
||
|
||
// Статический метод для получения карточек с токеном
|
||
static async getAllCards(apiKey: string, limit = 100): Promise<WildberriesCard[]> {
|
||
const service = new WildberriesService(apiKey)
|
||
|
||
// Если запрашивается больше 100 карточек, используем пагинацию
|
||
if (limit > 100) {
|
||
return service.getAllCardsWithPagination(limit)
|
||
}
|
||
|
||
const response = await service.getCards({ limit })
|
||
return response.cards
|
||
}
|
||
|
||
// Статический метод для поиска карточек с токеном
|
||
static async searchCards(apiKey: string, searchTerm: string, limit = 100): Promise<WildberriesCard[]> {
|
||
const service = new WildberriesService(apiKey)
|
||
return service.searchCards(searchTerm, limit)
|
||
}
|
||
|
||
// Утилитные методы для работы с изображениями
|
||
static getCardImage(card: WildberriesCard, size: 'big' | 'c516x688' | 'c246x328' | 'square' | 'tm' = 'c516x688'): string {
|
||
if (card.photos && card.photos.length > 0) {
|
||
return card.photos[0][size] || card.photos[0].big || ''
|
||
}
|
||
|
||
// Fallback на mediaFiles для старых данных
|
||
if (card.mediaFiles && card.mediaFiles.length > 0) {
|
||
return card.mediaFiles[0]
|
||
}
|
||
|
||
return ''
|
||
}
|
||
|
||
static getCardImages(card: WildberriesCard): string[] {
|
||
if (card.photos && card.photos.length > 0) {
|
||
return card.photos.map(photo => photo.big || photo.c516x688 || photo.c246x328)
|
||
}
|
||
|
||
// 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<StocksReportOfficesResponse> {
|
||
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('\n=== ДЕТАЛЬНЫЙ АНАЛИЗ ОТВЕТА API ===')
|
||
if (response.data) {
|
||
console.log('✅ response.data существует')
|
||
if (response.data.regions) {
|
||
console.log('✅ response.data.regions существует, длина:', response.data.regions.length)
|
||
response.data.regions.forEach((region, regionIndex) => {
|
||
console.log(`\n📍 РЕГИОН ${regionIndex + 1}:`)
|
||
console.log(' - regionName:', region.regionName)
|
||
console.log(' - metrics:', region.metrics)
|
||
console.log(' - offices.length:', region.offices?.length || 0)
|
||
|
||
if (region.offices && region.offices.length > 0) {
|
||
region.offices.forEach((office, officeIndex) => {
|
||
console.log(`\n 🏢 СКЛАД ${officeIndex + 1}:`)
|
||
console.log(' - officeID:', office.officeID)
|
||
console.log(' - officeName:', office.officeName)
|
||
console.log(' - metrics:', office.metrics)
|
||
|
||
// Проверяем наличие метрик
|
||
if (office.metrics) {
|
||
console.log(' - stockCount:', office.metrics.stockCount || 0)
|
||
console.log(' - toClientCount:', office.metrics.toClientCount || 0)
|
||
console.log(' - fromClientCount:', office.metrics.fromClientCount || 0)
|
||
}
|
||
})
|
||
} else {
|
||
console.log(' ⚠️ Нет складов в этом регионе')
|
||
}
|
||
})
|
||
} else {
|
||
console.log('❌ response.data.regions отсутствует')
|
||
}
|
||
} else {
|
||
console.log('❌ response.data отсутствует')
|
||
}
|
||
console.log('=== КОНЕЦ АНАЛИЗА ===\n')
|
||
|
||
|
||
|
||
console.log(`WB Analytics API: Returning raw response for processing in component`)
|
||
return response
|
||
|
||
} catch (error) {
|
||
console.error('WB Analytics API: Error getting stocks report:', error)
|
||
return { data: { regions: [] } }
|
||
}
|
||
}
|
||
|
||
|
||
}
|
||
|
||
export { WildberriesService }
|