# 库存管理 API > 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ProductsStocksV2 --- ## 接口信息 | 项 | 值 | |---|---| | 方法 | POST | | 路径 | `/v2/products/stocks` | | 鉴权 | 需要 `Client-Id` + `Api-Key` | | 用途 | **设置/更新商品库存**(必须操作,否则商品不可购买) | --- ## 1. 重要约束 ⚠️ **商品导入成功后不会自动上架**,必须设置库存才能开售: ``` POST /v3/product/import → status=imported(商品已创建) ↓ POST /v2/products/stocks → 设置库存(商品可购买) ``` 未设置库存的商品: - ✅ 在卖家后台可见 - ❌ 前台不展示 - ❌ 无法购买 --- ## 2. 请求 ### 请求体 ```json { "stocks": [ { "offer_id": "MY-THERMOS-001", "product_id": 987654321, "stock": 100, "warehouse_id": 12345678 }, { "offer_id": "MY-THERMOS-002", "stock": 50, "warehouse_id": 12345678 } ] } ``` ### 参数说明 | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | stocks | array | ✅ 是 | 库存数组(最多 **100** 个) | | stocks[].offer_id | string | 条件 | 自己的货号(与 product_id 二选一) | | stocks[].product_id | integer | 条件 | Ozon 商品 ID(与 offer_id 二选一) | | stocks[].stock | integer | ✅ 是 | 库存数量。`0` = 无库存(下架) | | stocks[].warehouse_id | integer | ✅ 是 | 仓库 ID(见下节) | ⚠️ **必须提供 offer_id 或 product_id**(建议用 offer_id,更稳定)。 --- ## 3. 仓库 ID(warehouse_id) ### 获取仓库 ID **接口**:`POST /v1/warehouse/list` ```json {} ``` **响应**: ```json { "result": [ { "warehouse_id": 12345678, "name": "FBS 仓库-莫斯科", "can_print_act_in_advance": true, "is_rfbs": false, "has_postings_limit": false, "postings_limit": 0, "status": "working" } ] } ``` | 字段 | 说明 | |---|---| | warehouse_id | **仓库 ID**(设置库存时用) | | name | 仓库名称 | | is_rfbs | 是否 rFBS 仓库(Ozon 代发货) | | status | 状态:`working`(运行中)/ `disabled`(禁用) | ### 仓库类型 | 类型 | 说明 | warehouse_id | |---|---|---| | **FBS** | 自发货(Fulfillment by Seller) | 从 `/v1/warehouse/list` 获取 | | **FBO** | Ozon 发货(Fulfillment by Ozon) | 从 `/v1/warehouse/list` 获取 | | **rFBS** | Ozon 代发货(类似 FBO,但库存在卖家处) | `is_rfbs=true` | **推荐**:新商户优先用 **FBS**(自发货),灵活且门槛低。 --- ## 4. 响应 ### 成功响应(200) ```json { "result": [ { "errors": [], "offer_id": "MY-THERMOS-001", "product_id": 987654321, "updated": true, "warehouse_id": 12345678 }, { "errors": [ { "code": "PRODUCT_NOT_FOUND", "message": "Product not found" } ], "offer_id": "MY-THERMOS-999", "product_id": 0, "updated": false, "warehouse_id": 12345678 } ] } ``` ### 字段说明 | 字段 | 类型 | 说明 | |---|---|---| | updated | boolean | 是否更新成功 | | errors | array | 错误数组(失败时) | | offer_id | string | 货号(回显) | | product_id | integer | Ozon 商品 ID(回显) | | warehouse_id | integer | 仓库 ID(回显) | --- ## 5. 常见错误 | code | message | 原因 | 解决 | |---|---|---|---| | `PRODUCT_NOT_FOUND` | 商品不存在 | offer_id/product_id 错误或商品已删除 | 检查 ID 是否正确 | | `WAREHOUSE_NOT_FOUND` | 仓库不存在 | warehouse_id 错误 | 调 `/v1/warehouse/list` 获取正确 ID | | `INVALID_STOCK` | 库存值错误 | stock < 0 | 库存必须 ≥ 0 | | `PRODUCT_ARCHIVED` | 商品已归档 | 商品处于归档状态 | 先恢复商品再设库存 | --- ## 6. 使用场景 ### 场景 1:发布后设置初始库存 ```python async def publish_and_set_stock(product: Product, shop: Shop): """发布商品 + 设置库存(完整流程)""" # 1. 导入商品 resp = await ozon_client.import_products( shop.client_id_dec, shop.api_key_dec, [build_import_item(product)] ) task_id = resp["result"]["task_id"] # 2. 轮询直到成功 result = await wait_for_import(task_id) if not result["success"]: raise Exception(f"发布失败: {result['errors']}") product_id = result["product_id"] # 3. 获取仓库 ID warehouses = await ozon_client.get_warehouses( shop.client_id_dec, shop.api_key_dec ) warehouse_id = warehouses[0]["warehouse_id"] # 取第一个 # 4. 设置库存 stock_resp = await ozon_client.update_stocks( shop.client_id_dec, shop.api_key_dec, [{ "product_id": product_id, "stock": 100, # 初始库存 "warehouse_id": warehouse_id }] ) return stock_resp ``` ### 场景 2:批量更新库存 ```python async def batch_update_stocks(updates: list[dict]): """批量更新库存(最多 100 个)""" # updates = [ # {"offer_id": "PROD-001", "stock": 50}, # {"offer_id": "PROD-002", "stock": 0}, # 0 = 下架 # ] warehouse_id = await get_default_warehouse_id() stocks = [ { "offer_id": u["offer_id"], "stock": u["stock"], "warehouse_id": warehouse_id } for u in updates ] resp = await ozon_client.update_stocks( client_id, api_key, stocks ) # 检查失败项 failed = [ item for item in resp["result"] if not item["updated"] ] return { "success": len(resp["result"]) - len(failed), "failed": failed } ``` ### 场景 3:库存为 0 时下架 ```python async def out_of_stock(offer_id: str): """库存售罄,设为 0(自动下架)""" await ozon_client.update_stocks( client_id, api_key, [{ "offer_id": offer_id, "stock": 0, # 库存为 0 → 前台不展示 "warehouse_id": warehouse_id }] ) ``` ### 场景 4:补货后上架 ```python async def restock(offer_id: str, quantity: int): """补货后重新上架""" await ozon_client.update_stocks( client_id, api_key, [{ "offer_id": offer_id, "stock": quantity, # 设置新库存 → 自动上架 "warehouse_id": warehouse_id }] ) ``` --- ## 7. 查询当前库存 ### 接口信息 | 项 | 值 | |---|---| | 方法 | POST | | 路径 | `/v3/product/info/stocks` | | 用途 | 查询商品当前库存 | ### 请求 ```json { "filter": { "offer_id": ["MY-THERMOS-001"], "product_id": [987654321], "visibility": "ALL" }, "last_id": "", "limit": 100 } ``` ### 响应 ```json { "result": { "items": [ { "offer_id": "MY-THERMOS-001", "product_id": 987654321, "stocks": [ { "type": "fbs", "present": 100, "reserved": 5, "warehouse_id": 12345678, "warehouse_name": "FBS 仓库-莫斯科" } ] } ], "last_id": "", "total": 1 } } ``` | 字段 | 说明 | |---|---| | stocks[].present | 可用库存 | | stocks[].reserved | 已预订数量(订单未完成) | | stocks[].type | 仓库类型:`fbs` / `fbo` / `rfbs` | --- ## 8. 库存同步策略 ### 策略 A:实时同步(推荐) ```python # 本地库存变化时立即更新 Ozon async def on_local_stock_change(product_id: str, new_stock: int): product = await get_product(db, product_id) if not product.ozon_product_id: return # 未发布到 Ozon shop = await get_default_shop(db) warehouse_id = await get_default_warehouse_id() await ozon_client.update_stocks( shop.client_id_dec, shop.api_key_dec, [{ "offer_id": product.offer_id, "stock": new_stock, "warehouse_id": warehouse_id }] ) ``` ### 策略 B:定时同步 ```python # 每天凌晨同步一次(防止偏差累积) async def daily_sync_stocks(): """定时任务:同步本地库存到 Ozon""" products = await db.execute( select(Product) .where( Product.stage == "published", Product.ozon_product_id.isnot(None) ) ) products = products.scalars().all() warehouse_id = await get_default_warehouse_id() # 批量更新(100 个一批) for i in range(0, len(products), 100): batch = products[i:i+100] stocks = [ { "offer_id": p.offer_id, "stock": p.local_stock, # 假设有 local_stock 字段 "warehouse_id": warehouse_id } for p in batch ] await ozon_client.update_stocks( client_id, api_key, stocks ) ``` ### 策略 C:反向同步(从 Ozon 读回) ```python # 定期从 Ozon 读回库存(多渠道销售时需要) async def sync_stocks_from_ozon(): """从 Ozon 同步库存到本地""" resp = await ozon_client.get_product_stocks( filter={"visibility": "VISIBLE"}, limit=1000 ) for item in resp["result"]["items"]: offer_id = item["offer_id"] ozon_stock = item["stocks"][0]["present"] # 更新本地库存 await db.execute( update(Product) .where(Product.offer_id == offer_id) .values(local_stock=ozon_stock) ) await db.commit() ``` --- ## 9. V2 项目集成 ### API 层(待实现) ```python # server/api/products.py @router.post("/products/{product_id}/set-stock") async def set_stock( product_id: str, stock: int = Body(..., ge=0), shop_id: str = Body(...), db: AsyncSession = Depends(get_db) ): """设置商品库存""" product = await get_product(db, product_id) if not product.ozon_product_id and not product.offer_id: raise HTTPException(400, "商品尚未发布到 Ozon") shop = await get_shop(db, shop_id) # 获取仓库 ID(缓存) warehouse_id = await get_or_cache_warehouse_id(shop) # 调用 Ozon API resp = await ozon_client.update_stocks( shop.client_id_dec, shop.api_key_dec, [{ "offer_id": product.offer_id, "stock": stock, "warehouse_id": warehouse_id }] ) result = resp["result"][0] if not result["updated"]: raise HTTPException(500, f"更新失败: {result['errors']}") # 更新本地记录 product.local_stock = stock await db.commit() return {"success": True, "stock": stock} @router.get("/shops/{shop_id}/warehouses") async def get_warehouses( shop_id: str, db: AsyncSession = Depends(get_db) ): """获取店铺的仓库列表""" shop = await get_shop(db, shop_id) warehouses = await ozon_client.get_warehouses( shop.client_id_dec, shop.api_key_dec ) return {"result": warehouses} ``` ### 前端(待实现) ```tsx // studio/src/pages/product/components/StockPanel.tsx import { InputNumber, Button, message } from 'antd'; export function StockPanel({ productId, shopId }) { const [stock, setStock] = useState(0); const [saving, setSaving] = useState(false); const handleSave = async () => { setSaving(true); try { await fetch(`/api/products/${productId}/set-stock`, { method: 'POST', body: JSON.stringify({ stock, shop_id: shopId }) }); message.success(`库存已设置为 ${stock}`); } catch (err) { message.error(`设置失败: ${err.message}`); } finally { setSaving(false); } }; return (