name: fbo-plan
description: Ozon FBO 多集群发货 SOP 一把梭脚本 (create_fbo_plan.py). 当用户说"发 FBO"、"建 FBO 供货单"、"走 FBO SOP"、"multi-cluster 发货"、"多集群发货"、"按 plan.json 发 FBO"、"跑 FBO 计划"、"把 approved plan 推到 FBO"、"按 4181 plan 发货"、"按 batch_id 发货" 时触发。自动 multi-cluster 优先 + PARTIAL/NOT_AVAILABLE 降级到单集群 CROSSDOCK + 建箱+箱唛+对照表+ledger 台账。支持 3 种输入: plan.json / 4181 plan_id / 4181 batch_id.
Ozon FBO 多集群发货 SOP
一个脚本跑通: plan.json → draft → 分拆 (可发走 multi-cluster, 不可发走单集群 CROSSDOCK fallback) → supply_order → bundle 查实际 items → 切箱 → cargoes → 标签 PDF → Excel 对照表 → ledger 台账.
脚本: /Users/mac/Documents/ozns/丝绸生活/海外仓发货表/2026-04-23海外仓发货/create_fbo_plan.py (~1050 行)
触发场景
- "按 plan.json 发 FBO"
- "跑 FBO 发货 SOP"
- "多 SKU 多集群要发 FBO"
- "多集群发一票"
- "从卡累利阿 + 新西伯利亚发这几款"
前置依赖
-
FBO 代理服务 4182 必须健康 (docker 容器 ozon-fbo-shipment):
python3 -c "import urllib.request,json; print(json.load(urllib.request.urlopen('http://localhost:4182/health')).get('ok'))"
没起 → 先跑 /fbo-service-up.
-
plan.json 配置文件 (用户要提供, 或从 /restock-to-fbo 产出):
{
"source_warehouse_keyword": "ЖУКОВСКИЙ_РФЦ",
"drop_off_keyword": "ЩЕРБИНКА",
"matrix": {
"Новосибирск": {"Q_ChongQiZui-HuangSe-Free": 600, "Q_MeiGongDao-HeiSe-Free": 1800},
"Дальний Восток": {"Q_ChongQiZui-HuangSe-Free": 300, "Q_MeiGongDao-HeiSe-Free": 900}
}
}
drop_off_keyword 必须有时段的 SORTING_CENTER 关键字 (如 ЩЕРБИНКА; 不是所有 CROSS_DOCK 都行).
source_warehouse_keyword 卖家发货仓关键字 (如 ЖУКОВСКИЙ_РФЦ); 可选.
matrix 键=集群俄文名/关键字, 值={offer_id: 件数}. 件数 > 0.
SOP 6 步 (脚本内固化)
0. /health + 账号存在性
1. 解析 clusters (名→macrolocal_cluster_id) / SKUs (offer_id→数字 sku)
/ drop-off (SORTING_CENTER 带时段) / seller warehouse (可选)
2. POST /draft/multi-cluster/create + 轮询 /draft/create/info (v2)
3. 按 availability_status 分拆:
- AVAILABLE → multi-cluster 一票
- PARTIAL_AVAILABLE / NOT_AVAILABLE → 降级单集群 CROSSDOCK fallback
- 若 multi_ok < 2 集群, 全部降级 CROSSDOCK (单集群走 multi-cluster 无意义)
4. multi-cluster: timeslot/info → supply/create (v2) → create/status
5. 每个 fallback 集群: draft/crossdock/create → timeslot → supply/create → status
6. 所有成功 supply_order:
- 先 /supply-order/bundle 拿 **supply 实际接受的 items** (Ozon 会悄悄过滤!)
- 按实际 items 切箱 → /flow/upload-cargoes (批量 cargoes/create + label/create + PDF)
- 写 Excel 对照表 (22 列)
- 未发的写 ledger.skipped (reason_code: MATRIX_DROPPED_FROM_SUPPLY / NO_AVAILABLE_WAREHOUSE / DRAFT_OR_SUPPLY_FAILED)
三种输入源
源 1: plan.json (原生)
手工维护 matrix, 适合测试或非决策驱动场景.
源 2: 4181 plan_id (HITL 集成) ★★★
已有 /decide/batch 跑过 + Bitable 审批到 status=approved 的 plan, 直接喂过来:
python3 create_fbo_plan.py --plan-id <pid> --account 丝绸生活
python3 create_fbo_plan.py --plan-id <pid1>,<pid2> --account 丝绸生活
- box_size 自动取 plan.box_size,
--box-size 可覆盖
- source/drop-off keyword 用
--source-warehouse-keyword / --drop-off-keyword (默认 ЖУКОВСКИЙ_РФЦ / ЩЕРБИНКА)
- 非 approved → 报错退出 (安全门)
- 建单成功 (ledger.shipped 有该 plan 的 SKU) → 自动 POST
/plans/<pid>/transition {to_status:dispatched} (4181 状态机)
- 全 skipped (矩阵拒) → plan 保留 approved, 可
/fbo-retry 后续重试
源 3: 4181 batch_id (批次一把梭)
一个 /decide/batch 产生的多 SKU plan 合并发:
python3 create_fbo_plan.py --batch-id <bid> --account 丝绸生活
- 内部扫 /plans 列表 + 逐个拉 detail 筛 batch_id (列表 view 不暴露 batch_id)
- 要求批次内所有 plan 都 approved
- 所有 plan 的 box_size 必须一致 (不同 → 报错, 用 --box-size 覆盖或分批)
执行命令
标准跑 (真实建单)
cd /Users/mac/Documents/ozns/丝绸生活/海外仓发货表/2026-04-23海外仓发货
python3 create_fbo_plan.py \
--config plan.json \
--box-size 300 \
--account 丝绸生活
dry-run (只探可发性, 不建单)
python3 create_fbo_plan.py --config plan.json --box-size 300 --account 丝绸生活 --dry-run
输出: 每集群 availability_status (AVAILABLE / PARTIAL_AVAILABLE / NOT_AVAILABLE + reason) + 分拆方案打印.
建完 supply 就停 (不填箱不拉标签, 用于稍后人工确认后再手工申报)
python3 create_fbo_plan.py --config plan.json --box-size 300 --account 丝绸生活 --skip-fill
之后用 /fbo-fill-boxes 跑 fill_boxes_labels.py --plan-result <ledger.json>.
调整时段窗口 (默认 3~28 天后)
python3 create_fbo_plan.py ... --days-from 5 --days-to 14
全参数
--config (必填): plan.json 路径
--box-size (必填): 单箱装箱率
--account: 默认 丝绸生活
--fbo-service: 默认 http://127.0.0.1:4182
--out-xlsx: 对照表输出路径, 默认 fbo_plan_<date>.xlsx
--dry-run: 只探不建
--skip-fill: 建完 supply 停
--days-from / --days-to: 时段窗口 (默认 3/28)
关键点 / 坑 (都已在脚本里处理, 但要知道)
-
Ozon 静默过滤 SKU: 任何模式建完 supply 都可能悄悄过滤 SKU, 申报装箱前必须 /v1/supply-order/bundle 拿实际 items 再切箱. 脚本内 fetch_supply_actual_items() 已实现. 过滤的部分自动进 ledger.skipped (reason_code=MATRIX_DROPPED_FROM_SUPPLY).
-
drop-off 必须选 SORTING_CENTER 带时段: CROSS_DOCK 类仓可能没 multi-cluster 时段, 选 ЩЕРБИНКА 这类 SORTING_CENTER.
-
PARTIAL_AVAILABLE 不保留 multi-cluster: 无法精确识别 bundle 里可发的 SKU 子集, 强发会被 supply/create 拒. 直接 fallback 到单集群 CROSSDOCK.
-
multi_ok < 2 → 全部走 CROSSDOCK: 1 个集群走多集群无意义.
-
矩阵限制典型: 远东 NOT_AVAILABLE_MATRIX 对 Q_MeiGongDao+Q_ChongQiZui 组合 (2026-04-24 实测).
-
v1↔v2 timestamp Z 反转: v1 supply/create 要 Z 后缀, v2 拒绝. 脚本已按 v2 走.
-
NOT_AVAILABLE_MATRIX/ROUTE 不再 single-fallback (2026-04-25): multi-cluster 预检阶段, 这两类 reason 直接归 skipped (reason_code=NO_AVAILABLE_WAREHOUSE), 省 draft 配额. single CROSSDOCK 走的是同一矩阵, 救不活. 短间隔 (12h) retry 实测无效, 至少 24h+ 才有意义.
-
80h safe_cancel 规则: 取消 supply 必先 shift timeslot ≥ 80h (safe_cancel_supply 落地), 否则 Ozon 罚款. ISO+Z 格式 (UTC).
-
MIN_BOX_FILL_RATE=0.5 自动 cancel: bundle 拿到的 actual qty / planned qty < 50% 触发自动 cancel, 防 supply 被 trim 后只发几件还要走全流程 (Саратов/Казань ~52-58 件 hard cap 反例已落地). 跟 80h 规则联动 (即先 shift 再 cancel).
-
残箱 ≥ box_size/2 否则 floor: 件数若有不足半箱残箱直接砍掉. list_pending --emit-plan 入口 + create_fbo_plan 入口都已落地. 实物装箱限制.
产出物
脚本跑完, 当前目录下:
-
Excel 对照表: fbo_plan_<YYYYMMDD_HHMMSS>.xlsx (22 列: 交货 ID / 供货 ID / 状态 / 链路 / 集群 / macrolocal / 货位 / drop-off / 货号 / SKU / 条码 / 箱号 / cargo_id / 该箱件数 / 总件数 / 总箱数 / 装箱率 / 时段 / PDF 路径 / 备注)
-
ledger 台账: ledger/shipment_ledger_<YYYYMMDD_HHMMSS>.json (shipped[] + skipped[])
-
pending_summary.json: 跨 run 聚合的未发清单 (自动去重 (cluster, offer_id, sku), 保留最新 timestamp)
-
箱唛 PDF: 每个 supply_order 一份, 路径在 Excel 的"箱唛 PDF 路径"列和 ledger.shipped[].pdf_path.
验证建议
跑完看:
tail -20 ledger/shipment_ledger_<stamp>.json 看 shipped 数
- Excel 打开看有没有红色行 (备注列有错误)
- Ozon seller.ozon.ru「供货单」页面能搜到 order_id
- 箱唛 PDF 能打开
常见问题
Q1: 整个 multi-cluster 全拒, 全部降 CROSSDOCK 了, 对吗?
A: 看 availability_status. 如果所有集群都是 NOT_AVAILABLE_MATRIX, 可能是这批 SKU 组合在业务矩阵里全被拒. dry-run 看分拆再决定要不要拆 plan.
Q2: supply_order 建了但 cargoes/create 返 SUPPLY_ITEM_NOT_FOUND
A: 脚本自动调 /supply-order/bundle 先拿实际 items 避免这个. 如果还出, 可能 supply 被改过, 重跑或手动 /supply-order/get 核对.
Q3: DROP_OFF_POINT_HAS_NO_TIMESLOTS
A: drop_off_keyword 选错了. 换 ЩЕРБИНКА ХАБ 这种 SORTING_CENTER 关键字, 不是 КРОССДОКИНГ 类.
Q4: 账号不在 .env
A: 去 /Users/mac/Documents/ozns/github/ozon_fbo_shipment_service/.env 加 OZON_ACCOUNTS_JSON, docker compose restart.
Q5: 有些 SKU 一直被过滤, 想跳过重发剩下
A: 不用手改 plan. 等脚本跑完, ledger 里已记 skipped. 用 /fbo-retry 导 retry 计划, 等矩阵松了再跑.
相关 skill / memory
/fbo-service-up — 4182 服务运维
/fbo-retry — 查/重试未发
/fbo-fill-boxes — 单独填装箱+箱唛 (--skip-fill 后补流程)
/fbo-status — 台账查询
/restock-to-fbo — 从补货决策直出 plan.json
- memory
reference_create_fbo_plan_sop.md — SOP 契约 + 完整配置 shape
- memory
reference_shipment_ledger.md — ledger schema
- memory
feedback_always_verify_supply_actual_items.md — 为什么必须查 bundle
- memory
reference_ozon_fbo_api.md — v1↔v2 路径 / supply_type 枚举 / Ozon 坑点