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, ]