14 KiB
14 KiB
库存管理 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. 请求
请求体
{
"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
{}
响应:
{
"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)
{
"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:发布后设置初始库存
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:批量更新库存
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 时下架
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:补货后上架
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 |
| 用途 | 查询商品当前库存 |
请求
{
"filter": {
"offer_id": ["MY-THERMOS-001"],
"product_id": [987654321],
"visibility": "ALL"
},
"last_id": "",
"limit": 100
}
响应
{
"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:实时同步(推荐)
# 本地库存变化时立即更新 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:定时同步
# 每天凌晨同步一次(防止偏差累积)
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 读回)
# 定期从 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 层(待实现)
# 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}
前端(待实现)
// 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 (
<div>
<InputNumber
min={0}
value={stock}
onChange={setStock}
placeholder="库存数量"
/>
<Button
type="primary"
loading={saving}
onClick={handleSave}
>
设置库存
</Button>
<div style={{ marginTop: 8, fontSize: 12, color: '#888' }}>
💡 库存为 0 时商品自动下架
</div>
</div>
);
}
10. 最佳实践
1. 发布流程中必须设库存
✅ 正确:
POST /v3/product/import → 轮询成功 → POST /v2/products/stocks
❌ 错误:
POST /v3/product/import → 轮询成功 → 结束(商品不可购买)
2. 缓存仓库 ID
# 仓库 ID 不常变,启动时拉取并缓存
_warehouse_cache = {}
async def get_warehouse_id(shop_id: str):
if shop_id not in _warehouse_cache:
warehouses = await ozon_client.get_warehouses(...)
_warehouse_cache[shop_id] = warehouses[0]["warehouse_id"]
return _warehouse_cache[shop_id]
3. 库存为 0 的处理
# 库存为 0 → 自动下架,但商品仍在后台
# 补货后再设置库存 → 自动上架
# 不需要删除商品,只需更新库存
4. 批量操作
# 单次最多 100 个,超过需分批
async def update_large_batch(stocks: list):
results = []
for i in range(0, len(stocks), 100):
batch = stocks[i:i+100]
resp = await ozon_client.update_stocks(client_id, api_key, batch)
results.extend(resp["result"])
return results
相关文档
- 04-product-import.md —— 创建商品(发布前置)
- 05-product-info.md —— 查询商品信息(含库存)
- 10-prices.md —— 价格更新
- docs/v2/ozon-publish.md §5 —— 发布链路(含库存设置)