×

《1688 × 闲鱼 × Mercari 的统一采购适配层:ExternalOrderNo / OfferId / ListingId 的三角映射 + 供应商备供切换状态机》(附Python源码)

万邦科技Lex 万邦科技Lex 发表于2026-09-29 09:48:13 浏览30 评论0

抢沙发发表评论

《1688 × 闲鱼 × Mercari 的统一采购适配层:ExternalOrderNo / OfferId / ListingId 的三角映射 + 供应商备供切换状态机》(附Python源码)

一、先把这个“三角”说清楚(很多人一开始就把 ID 混了)

平台
商品侧 ID
订单侧 ID
外部单号字段
本质
1688
offerId / item_id(商品唯一标识)
purchase_order_id
outerOrderNo / externalOrderNo(下游单号回传)
供货侧:买别人的货
闲鱼
item_id(卖家商品ID) / isvItemId(服务商商品ID)
biz_order_id
outerOrderId(下单页透传)
销售侧:卖自己的闲置
Mercari
listing_id / item_id(如 m14214569688)
order_id(平台内交易单)
通常自己系统生成 erp_order_id
销售侧:C2C 转售
核心认知:
OfferId = 货源坐标,ListingId = 销售渠道上的商品坐标,biz_order_id / purchase_order_id = 交易坐标,ExternalOrderNo = 你 ERP 里的“主键锚点”。
统一采购适配层干的事,就是把“卖出去的一单”反向定位到“1688 上该找哪个 offer / sku / 供应商下单”。

二、统一映射模型(Unified Procurement Mapping)

# procurement_mapping.py
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional


class SalesChannel(str, Enum):
    XIANYU = "xianyu"
    MERCARI = "mercari"


class SupplyChannel(str, Enum):
    ALI1688 = "1688"


@dataclass(frozen=True)
class SourceListingRef:
    """销售渠道上的商品坐标"""
    channel: SalesChannel
    listing_id: str          # 闲鱼 item_id / Mercari mxxxx
    sku_id: Optional[str] = None


@dataclass(frozen=True)
class SupplyOfferRef:
    """供货渠道上的商品坐标"""
    channel: SupplyChannel = SupplyChannel.ALI1688
    offer_id: str = ""       # 1688 offerId
    sku_id: str = ""         # 1688 skuId / spec_id
    supplier_id: str = ""


@dataclass
class ProcurementBinding:
    """
    核心三角映射:
      erp_order_id  ──1──▶  SourceListingRef   (在哪个平台卖了)
                        └──▶  SupplyOfferRef     (去哪里采购)
                        └──▶  external_order_no  (1688 分销单回传用)
    """
    erp_order_id: str
    source: SourceListingRef
    supply: SupplyOfferRef
    external_order_no: str = ""        # 1688 侧 outerOrderNo
    purchase_order_id: Optional[str] = None
    active_supplier_rank: int = 0      # 当前用第几个备供
    version: int = 1                   # 映射变更版本,防并发覆盖
    created_at: float = field(default_factory=lambda: __import__("time").time())

映射注册表(带幂等)

class ProcurementMappingRegistry:
    def __init__(self):
        # erp_order_id -> ProcurementBinding
        self.by_order: dict[str, ProcurementBinding] = {}
        # (channel, listing_id, sku_id) -> erp_order_id
        self.by_listing: dict[tuple, str] = {}

    def bind(self, binding: ProcurementBinding):
        # 幂等:已存在且 version 不旧于新版本则跳过
        old = self.by_order.get(binding.erp_order_id)
        if old and old.version >= binding.version:
            return old

        self.by_order[binding.erp_order_id] = binding
        self.by_listing[(binding.source.channel.value,
                         binding.source.listing_id,
                         binding.source.sku_id)] = binding.erp_order_id
        return binding

    def resolve_by_listing(self, channel: str, listing_id: str, sku_id: str = None) -> Optional[ProcurementBinding]:
        oid = self.by_listing.get((channel, listing_id, sku_id))
        return self.by_order.get(oid) if oid else None

    def attach_purchase_result(self, erp_order_id: str,
                              purchase_order_id: str, external_order_no: str):
        b = self.by_order.get(erp_order_id)
        if not b:
            raise KeyError(f"未绑定采购关系: {erp_order_id}")
        self.by_order[erp_order_id] = ProcurementBinding(
            **{**b.__dict__,
               "purchase_order_id": purchase_order_id,
               "external_order_no": external_order_no,
               "version": b.version + 1}
        )

三、供应商备供切换状态机(主供售罄/断货/物流失败才切)

状态定义

class SupplierState(str, Enum):
    IDLE = "idle"               # 未使用
    ACTIVE = "active"           # 当前主供
    PRECHECK_FAIL = "precheck_fail"   # 预览/库存不行
    ORDER_FAIL = "order_fail"   # 下单失败
    SHIP_FAILED = "ship_failed" # 发货/物流回传失败
    FALLBACK = "fallback"       # 已切到备供
    DISABLED = "disabled"       # 人工禁用

备供表

@dataclass
class SupplierCandidate:
    supplier_id: str
    offer_id: str
    sku_id: str
    priority: int               # 0 = 主供
    state: SupplierState = SupplierState.IDLE
    last_error: str = ""
    cost: float = 0.0
    freight: float = 0.0
    available: bool = True

状态机

class BackupSupplierFSM:
    """
    切换触发条件:
    1. createOrder.preview 返回库存不足 / 报价异常
    2. trade.fenxiaoOrder.create 返回 SUB_ORDER_NOT_ENOUGH
    3. 物流回传静默失败(shipped 但无 tracking_no)
    4. 供应商被人工禁用
    """

    def __init__(self, candidates: list[SupplierCandidate]):
        self.candidates = sorted(candidates, key=lambda c: c.priority)

    def current(self) -> SupplierCandidate:
        for c in self.candidates:
            if c.state == SupplierState.ACTIVE:
                return c
        # 自动选第一个可用
        for c in self.candidates:
            if c.state in (SupplierState.IDLE,) and c.available:
                c.state = SupplierState.ACTIVE
                return c
        raise RuntimeError("无可用供应商")

    def on_precheck_fail(self, c: SupplierCandidate, reason: str):
        c.state = SupplierState.PRECHECK_FAIL
        c.last_error = reason
        # 切下一个
        self._promote_next(c)

    def on_order_fail(self, c: SupplierCandidate, reason: str):
        c.state = SupplierState.ORDER_FAIL
        c.last_error = reason
        self._promote_next(c)

    def on_ship_failed(self, c: SupplierCandidate, reason: str):
        # 物流失败不一定换货,但记录并告警
        c.state = SupplierState.SHIP_FAILED
        c.last_error = reason

    def _promote_next(self, failed: SupplierCandidate):
        idx = self.candidates.index(failed)
        for nxt in self.candidates[idx + 1:]:
            if nxt.available and nxt.state in (SupplierState.IDLE,):
                nxt.state = SupplierState.ACTIVE
                failed.state = SupplierState.FALLBACK
                return
        # 没得切了
        failed.state = SupplierState.DISABLED
        raise RuntimeError("主备供全部不可用,需人工介入")

    def snapshot(self):
        return [
            {"supplier": c.supplier_id, "priority": c.priority,
             "state": c.state.value, "cost": c.cost + c.freight}
            for c in self.candidates
        ]

四、统一采购编排器(闲鱼/Mercari 卖出 → 1688 采购)

# procurement_orchestrator.py
import time

# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
class ProcurementOrchestrator:
    def __init__(self, registry, ali1688_client, qps_limiter):
        self.registry = registry
        self.ali = ali1688_client
        self.limiter = qps_limiter

    def fulfill(self, erp_order_id: str, source: SourceListingRef,
                buyer_receiver: dict, channel: str) -> dict:
        """
        1. 解析销售单 -> 找到 1688 供货绑定
        2. 主供 preview
        3. 失败切备供
        4. 下单 + 回写 external_order_no
        """

        # ---- 步骤1: 商品映射(闲鱼 item_id / Mercari listing_id -> 1688 offer)
        binding = self.registry.resolve_by_listing(
            channel, source.listing_id, source.sku_id)
        if not binding:
            # 没绑定过:进入选品池人工/规则绑定(不自动瞎买)
            return {"ok": False, "code": "NO_SUPPLY_BINDING",
                    "msg": "销售单未绑定供货 offer,禁止盲采"}

        fsm = binding._fsm  # 实际实现里 Binding 持有一个 FSM 或在外面管
        supplier = fsm.current()

        # ---- 步骤2: 下单前预览(防超卖/错价)
        pre = self.ali.preview(binding.erp_order_id, PurchaseReq(
            offer_id=supplier.offer_id,
            sku_id=supplier.sku_id,
            qty=buyer_receiver.get("qty", 1),
            receiver=buyer_receiver,
            outer_order_no=erp_order_id,
            channel=channel,
        ))
        if not pre["ok"]:
            fsm.on_precheck_fail(supplier, pre.get("code", "preview_fail"))
            supplier = fsm.current()
            # 再试一次主备供
            pre = self.ali.preview(binding.erp_order_id, PurchaseReq(
                offer_id=supplier.offer_id,
                sku_id=supplier.sku_id,
                qty=buyer_receiver.get("qty", 1),
                receiver=buyer_receiver,
                outer_order_no=erp_order_id,
                channel=channel,
            ))
            if not pre["ok"]:
                return {"ok": False, "code": "ALL_SUPPLIERS_PRECHECK_FAIL",
                        "snapshot": fsm.snapshot()}

        # ---- 步骤3: 创建 1688 分销/代发单
        self.limiter.acquire("trade.fenxiaoOrder.create")
        order = self.ali.create_purchase_order(binding.erp_order_id, PurchaseReq(
            offer_id=supplier.offer_id,
            sku_id=supplier.sku_id,
            qty=buyer_receiver.get("qty", 1),
            receiver=buyer_receiver,
            outer_order_no=erp_order_id,
            channel=channel,
        ))
        if not order["ok"]:
            fsm.on_order_fail(supplier, order.get("code"))
            return {"ok": False, "code": "ORDER_FAIL",
                    "snapshot": fsm.snapshot()}

        # ---- 步骤4: 回写三角映射
        self.registry.attach_purchase_result(
            erp_order_id=binding.erp_order_id,
            purchase_order_id=order["purchase_order_id"],
            external_order_no=erp_order_id,   # 1688 outerOrderNo = 自己 ERP 单号
        )

        return {
            "ok": True,
            "erp_order_id": binding.erp_order_id,
            "purchase_order_id": order["purchase_order_id"],
            "supplier": supplier.supplier_id,
            "external_order_no": erp_order_id,
        }

五、和前几篇的拼接

  • 《1688 API 全链路》:本文把“选品 → preview → fenxiaoOrder.create → 物流重推”收进编排器

  • 《闲鱼 order.ship》:闲鱼侧 biz_order_id 发货;本文是“闲鱼卖出后去 1688 买”,方向反过来

  • 《两套接口边界》:1688 商品采集 ≠ 1688 代发下单;offerId 进“选品池”,绑定后才能进“采购池”

  • 《六大坑》:这里新增第 7 坑——“销售单和采购单的 ID 体系不一致,导致退款/物流追丢”

  • 《中台调度》:Ali1688Adapter 只懂供货,XianyuAdapter/MercariAdapter 只懂销售,ProcurementOrchestrator 是中间那根轴


六、生产落地铁律

  1. 销售单不直接调 1688:必须先有 SourceListing → SupplyOffer 绑定

  2. ExternalOrderNo 用 ERP 主键:别用平台单号当外键,平台单号会变

  3. preview 必跑:1688 库存是快照,不 preview 必超卖

  4. 备供切换只因失败:别为“便宜 5 毛”动态换供,物流/售后会炸

  5. purchase_order_id 必须落库:后续退款/拦截/截单都靠它

  6. Mercari 无官方代发:1688 采购后只能自己/货代发日本,别以为 Mercari 有“一键代发”

  7. 闲鱼 outerOrderId 是透传字段:用来把支付单和 ERP 单对齐,不是订单号本体


七、一句话收口

多平台二手 ERP 的采购层,不是“能调 1688 接口”,
而是把 ListingId(在哪卖)→ OfferId(去哪买)→ ExternalOrderNo(我系统里是谁) 三根线焊死,
再在供应商挂掉时,用状态机把单子无声无息切到下一根供货线——买家无感,财务能对,售后能追。

群贤毕至

访客