File size: 9,322 Bytes
da9b704
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
import os
from typing import Any, Optional

import httpx
from langchain_core.tools import tool

API_URL = os.getenv("API_URL", "https://levinaleksey-wb.hf.space").rstrip("/")
HTTP_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "30.0"))

ALLOWED_STOCKS_STATUSES = {"out_of_stock", "critical", "low", "ok"}
ALLOWED_STOCKS_SORT = {"days_of_stock", "total_stock", "avg_daily_sales_7d", "restock_needed_30d"}

ALLOWED_LOGISTICS_STATUSES = {"high_logistics", "has_penalties", "high_returns", "ok"}
ALLOWED_LOGISTICS_SORT = {"logistics_total", "delivery_total", "storage_total", "penalty_total", "return_rate_pct"}


async def _api_get(path: str, params: dict[str, Any] | None = None) -> Any:
    """Единый helper для GET-запросов к внешнему API."""
    async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
        if params:
            params = {k: v for k, v in params.items() if v is not None}
        response = await client.get(f"{API_URL}{path}", params=params)
        response.raise_for_status()
        return response.json()

async def _api_post(path: str, body: dict[str, Any]) -> Any:
    """Единый helper для POST-запросов к внешнему API."""
    async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
        response = await client.post(f"{API_URL}{path}", json=body)
        response.raise_for_status()
        return response.json()


# ==================== ОСТАТКИ (STOCKS) ====================

@tool
async def get_stocks_summary() -> Any:
    """Общая сводка по остаткам.
    
    Возвращает: всего товаров, остатки FBO/FBS, количество товаров по статусам 
    (out_of_stock, critical, low, ok) и общее необходимое пополнение.
    """
    return await _api_get("/stocks/summary")

@tool
async def get_stocks_products(
    limit: int = 50,
    status: Optional[str] = None,
    sort_by: str = "days_of_stock"
) -> Any:
    """Список товаров с остатками, фильтрацией и сортировкой.
    
    Args:
        limit: Количество товаров.
        status: Статус остатков. Допустимые: 'out_of_stock', 'critical', 'low', 'ok'.
        sort_by: Поле сортировки. Допустимые: 'days_of_stock', 'total_stock', 
                 'avg_daily_sales_7d', 'restock_needed_30d'.
    """
    if status is not None and status not in ALLOWED_STOCKS_STATUSES:
        raise ValueError(f"Некорректный status='{status}'. Допустимо: {sorted(ALLOWED_STOCKS_STATUSES)}")
    if sort_by not in ALLOWED_STOCKS_SORT:
        raise ValueError(f"Некорректный sort_by='{sort_by}'. Допустимо: {sorted(ALLOWED_STOCKS_SORT)}")
    
    return await _api_get("/stocks/products", {"limit": limit, "status": status, "sort_by": sort_by})

@tool
async def get_stock_product(nm_id: int) -> Any:
    """Полная информация по остаткам конкретного товара.
    
    Args:
        nm_id: Артикул Wildberries.
    """
    return await _api_get(f"/stocks/product/{nm_id}")

@tool
async def get_out_of_stock_products(limit: int = 50) -> Any:
    """Товары с нулевыми остатками (упущенная выручка)."""
    return await _api_get("/stocks/out-of-stock", {"limit": limit})

@tool
async def get_critical_stock_products(limit: int = 50) -> Any:
    """Критически низкие остатки (хватит менее чем на 7 дней)."""
    return await _api_get("/stocks/critical", {"limit": limit})

@tool
async def get_restock_plan(days: int = 30, limit: int = 50) -> Any:
    """План пополнения остатков.
    
    Расчёт необходимого количества для закупки на N дней вперёд 
    на основе средних продаж.
    
    Args:
        days: На сколько дней планируем запас.
        limit: Количество товаров.
    """
    return await _api_get("/stocks/restock-plan", {"days": days, "limit": limit})

@tool
async def get_warehouse_distribution() -> Any:
    """Общая статистика распределения остатков по складам FBO."""
    return await _api_get("/stocks/warehouses")

@tool
async def get_product_warehouse_distribution(nm_id: int) -> Any:
    """Распределение конкретного товара по складам (где именно он лежит)."""
    return await _api_get(f"/stocks/product/{nm_id}/warehouses")


# ==================== ЛОГИСТИКА (LOGISTICS) ====================

@tool
async def get_logistics_summary() -> Any:
    """Общая сводка по логистике за 30 дней.
    
    Возвращает суммы за доставку, хранение, штрафы, общий процент возвратов
    и долю расходов на логистику в GMV.
    """
    return await _api_get("/logistics/summary")

@tool
async def get_logistics_products(
    limit: int = 50,
    status: Optional[str] = None,
    sort_by: str = "logistics_total"
) -> Any:
    """Список товаров с затратами на логистику.
    
    Args:
        limit: Количество товаров.
        status: Фильтр. Допустимые: 'high_logistics', 'has_penalties', 'high_returns', 'ok'.
        sort_by: Сортировка. Допустимые: 'logistics_total', 'delivery_total', 
                 'storage_total', 'penalty_total', 'return_rate_pct'.
    """
    if status is not None and status not in ALLOWED_LOGISTICS_STATUSES:
        raise ValueError(f"Некорректный status='{status}'. Допустимо: {sorted(ALLOWED_LOGISTICS_STATUSES)}")
    if sort_by not in ALLOWED_LOGISTICS_SORT:
        raise ValueError(f"Некорректный sort_by='{sort_by}'. Допустимо: {sorted(ALLOWED_LOGISTICS_SORT)}")
        
    return await _api_get("/logistics/products", {"limit": limit, "status": status, "sort_by": sort_by})

@tool
async def get_logistics_product(nm_id: int) -> Any:
    """Детализация всех затрат на логистику, возвратов и штрафов по конкретному товару."""
    return await _api_get(f"/logistics/product/{nm_id}")

@tool
async def get_high_logistics_cost(limit: int = 50) -> Any:
    """Товары, где логистика съедает более 25% от GMV."""
    return await _api_get("/logistics/high-cost", {"limit": limit})

@tool
async def get_logistics_penalties(limit: int = 50) -> Any:
    """Список товаров, по которым есть начисленные штрафы от WB."""
    return await _api_get("/logistics/penalties", {"limit": limit})

@tool
async def get_high_returns(limit: int = 50) -> Any:
    """Товары с аномально высоким процентом возвратов (> 20%)."""
    return await _api_get("/logistics/returns", {"limit": limit})

@tool
async def get_logistics_profitability(limit: int = 50) -> Any:
    """Анализ прибыльности товаров строго после вычета затрат на логистику."""
    return await _api_get("/logistics/profitability", {"limit": limit})

@tool
async def get_logistics_trend(days: int = 30) -> Any:
    """Динамика (тренд) затрат на доставку, хранение и штрафы по дням."""
    return await _api_get("/logistics/trend", {"days": days})

@tool
async def get_commissions_by_category() -> Any:
    """Справочник тарифов и комиссий WB (FBO/FBS) по категориям товаров."""
    return await _api_get("/logistics/commissions")


# ==================== ИНСАЙТЫ ====================

@tool
async def add_logistics_insight(nm_id: int, tag: str, recommendation: str, score: float) -> Any:
    """Сохранить сгенерированную рекомендацию по остаткам/логистике в базу.
    
    Args:
        nm_id: Артикул Wildberries.
        tag: Тег (например 'restock_fbo', 'reduce_returns', 'investigate_penalty').
        recommendation: Текст конкретной рекомендации.
        score: Уверенность (от 0.5 до 1.0).
    """
    return await _api_post("/insights/add", {
        "nm_id": nm_id,
        "tag": tag,
        "recommendation": recommendation,
        "score": score
    })


# Экспортируем все 18 инструментов
logistics_tools = [
    get_stocks_summary,
    get_stocks_products,
    get_stock_product,
    get_out_of_stock_products,
    get_critical_stock_products,
    get_restock_plan,
    get_warehouse_distribution,
    get_product_warehouse_distribution,
    get_logistics_summary,
    get_logistics_products,
    get_logistics_product,
    get_high_logistics_cost,
    get_logistics_penalties,
    get_high_returns,
    get_logistics_profitability,
    get_logistics_trend,
    get_commissions_by_category,
    add_logistics_insight,
]