feat: 开发采集、采集箱和商品编辑功能
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
# 库存管理 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 (
|
||||
<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
|
||||
|
||||
```python
|
||||
# 仓库 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 的处理
|
||||
|
||||
```python
|
||||
# 库存为 0 → 自动下架,但商品仍在后台
|
||||
# 补货后再设置库存 → 自动上架
|
||||
# 不需要删除商品,只需更新库存
|
||||
```
|
||||
|
||||
### 4. 批量操作
|
||||
|
||||
```python
|
||||
# 单次最多 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](./04-product-import.md) —— 创建商品(发布前置)
|
||||
- [05-product-info.md](./05-product-info.md) —— 查询商品信息(含库存)
|
||||
- [10-prices.md](./10-prices.md) —— 价格更新
|
||||
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §5 —— 发布链路(含库存设置)
|
||||
Reference in New Issue
Block a user