×

🛒《京东POP二手品类对接:B2C订单模型 vs 二手C2C属性的字段转换难题》(附Python源码)

万邦科技Lex 万邦科技Lex 发表于2026-09-08 11:31:24 浏览26 评论0

抢沙发发表评论

🛒《京东POP二手品类对接:B2C订单模型 vs 二手C2C属性的字段转换难题》(附Python源码)

结论先拍:京东POP是B2C货架模型——ware(商品)/ sku(销售属性组合)/ props(类目属性)/ outerId(ERP外码)/ venderId(商家)/ 京东价/市场价/库存分离;而二手C2C属性是另一套语义:成色、质检报告、是否激活、电池健康、官翻/资源机/展机/样机、标题必须带“二手/95新/官翻机”前缀。 难点不是“字段对不上”,而是模型不对齐:闲鱼/转转是“一件闲置=一个商品”,京东POP是“一个SPU挂N个SKU,成色可能是SKU维度也可能是类目属性维度”。 正确做法是三层翻译:① 统一领域模型(SecondHandItem)② 京东特有模型映射(JdPopMapper)③ 成色/非全新词规范校验(标题前缀+属性组+详情一致)。 下面把坑和源码一次给全。

一、京东POP模型 vs 二手C2C属性:冲突点

维度
京东POP(B2C)
二手C2C属性
商品粒度
SPU(ware) + SKU(sku)
通常“一件一SKU”
成色位置
类目属性(props/attrId) 或 销售属性
商品主属性
标题规范
自由但受类目模板约束
必须前缀“二手/95新/官翻机/激活机/拆封机”
库存模型
sku维度库存、京东价/市场价分离
单件库存
订单模型
标准B2C(付款/拆分/发票/售后单/AFS)
二手还要“质检、寄卖、回收、售中质检”
外部码
outerId 挂 ware 上
闲鱼 outer_id / 转转 productId / 京东 outerId
属性系统
类目属性接口查 attrId→valueId
成色是“标签组/属性值”不是自由文本
京东官方商品发布链路是:有经营权限品牌 → 有经营权限类目 → 类目属性(findAttrByIdUnlimitCate) → 属性值(findValuesByAttrIdUnlimit) → ware.write.add → sku.write.saveWareSkus
二手商品还要遵守《非全新商品管理规则》:标题最前方加非全新属性词,详情页如实描述非全新信息

二、成色:三家又要归一,又要保留平台原义

统一模型里我们用 condition_int(0~100,闲鱼style),但:
  • 闲鱼:stuff_status int

  • 抖音:枚举字符串(99新/95新/8新,8新及以下禁售)

  • 京东POP:没有全局固定字段名,成色是“当前类目下某个 attrId 的 valueId”——比如手机类目成色属性可能是“99新/95新/9新/8新/官翻机/资源机/激活机”,值来自 findValuesByAttrIdUnlimit

  • 京东拍拍/二手3C体系里还有“成色标签组”:99新、95新、官换新机、全新

所以京东侧不能写死 condition="95新",必须:
  1. categoryId 查成色属性 attrId

  2. 再查 valueId(如 95新→tag_id/value_id)

  3. 把统一 condition_int 翻译成“该类目下合法的 valueId”

  4. 标题前缀 + 详情描述 + 成色属性三者一致


三、B2C订单模型 → 二手履约 的字段塌陷

京东订单是标准POP订单:orderId / orderTotalPrice / skuId / itemTotalPrice / invoice / afterSaleId(AFS)
二手交易会多出:
  • 是否官翻 / 资源机 / 展机 / 样机 / 激活机

  • 质检报告ID / 质检仓 / 寄卖服务商

  • 售中质检(jingdong.paipai.erp.inspected.order.operate 类接口)

  • 回收/寄卖/渠道订单支付通知

如果直接把“京东订单”当“闲鱼订单”处理,就会:
  • 用错售后接口(京东AFS ≠ 闲鱼RefundSync)

  • 忽略“售中质检/寄卖签约/渠道支付通知”

  • 把成色当普通销售属性,导致搜索降权/违规下架


四、Python:JdPopSecondHandAdapter(完整转换层)

# jd_pop_secondhand.py
"""
京东POP 二手品类对接: B2C货架模型 <-> 二手C2C属性 转换层
- 统一领域模型 SecondHandItem
- JdPopMapper: ware/sku/props/outerId/价格/库存 -> 统一模型
- ConditionResolver: condition_int <-> 京东类目成色属性(valueId)
- NonNewTitleValidator: 非全新标题前缀 + 详情一致 (京东规则)
- 订单侧: JdPopOrder -> 统一 Order (标记二手履约扩展)
复用前几篇: MasterSku / StockEngine / ComplianceGate / ProductRepository
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Any

# ==================== 统一二手领域模型 ====================
@dataclass
class SecondHandItem:
    master_sku: str
    platform: str = "jd_pop"
    title: str = ""
    non_new_prefix: str = ""        # 二手/95新/官翻机/资源机/激活机/拆封机
    condition_int: int = 0          # 统一成色 0~100 / -1准新
    condition_text: str = ""        # 95新
    jd_category_id: int = 0
    jd_attr_condition_id: Optional[int] = None     # 成色属性 attrId
    jd_condition_value_id: Optional[int] = None    # 成色属性值 valueId
    brand_id: Optional[int] = None
    sku_props: Dict[str, str] = field(default_factory=dict)  # 颜色/容量等
    jd_price: float = 0.0           # 京东价
    market_price: float = 0.0
    stock: int = 0
    outer_id: str = ""              # ERP外码 -> 京东 outerId
    quality_report_id: Optional[str] = None
    is_official_refurb: bool = False
    is_activated: bool = False
    detail_desc: str = ""

# ==================== 成色: 统一int <-> 文本 ====================
class ConditionIntMapper:
    INT_TO_TEXT = {
        100: "全新未拆封", 10: "全新", -1: "准新",
        99: "99新", 95: "95新", 9: "9新", 85: "85新", 8: "8新", 7: "7新",
    }
    TEXT_TO_INT = {v: k for k, v in INT_TO_TEXT.items()}

    def to_text(self, v: int) -> str:
        return self.INT_TO_TEXT.get(v, "9新")

    def to_int(self, text: str) -> int:
        return self.TEXT_TO_INT.get((text or "").strip(), 9)

# ==================== 京东类目成色解析器 ====================
class JdConditionResolver:
    """
    生产: 先 jingdong.category.read.findAttrsByCategoryIdUnlimitCate(categoryId)
         再 jingdong.category.read.findValuesByAttrIdUnlimit(attrId)
    这里用类目->成色属性映射模拟
    """
    # category_id -> (成色attr_id, {成色文本: value_id})
    CATE_CONDITION: Dict[int, tuple] = {
        15119: (90001, {"99新": 99001, "95新": 95001, "9新": 90001,
                        "8新": 80001, "官翻机": 70001, "资源机": 70002,
                        "激活机": 70003, "拆封机": 70004, "全新": 100}),
        9987:  (90002, {"99新": 99002, "95新": 95002, "9新": 90002, "8新": 80002}),
    }

    def resolve(self, item: SecondHandItem) -> SecondHandItem:
        cfg = self.CATE_CONDITION.get(item.jd_category_id)
        if not cfg:
            # 该类目无成色属性 -> 成色只能放标题/详情, 不能塞props
            item.jd_attr_condition_id = None
            item.jd_condition_value_id = None
            return item
        attr_id, values = cfg
        item.jd_attr_condition_id = attr_id
        vid = values.get(item.condition_text) or values.get(
            ConditionIntMapper().to_text(item.condition_int))
        if vid is None:
            raise JdMappingError(
                f"类目{item.jd_category_id}不支持成色'{item.condition_text}', "
                f"可选:{list(values.keys())}")
        item.jd_condition_value_id = vid
        return item

# ==================== 非全新标题/详情校验 (京东规则) ====================
NON_NEW_PREFIXES = ["二手", "95新", "99新", "9新", "8新", "官翻机", "资源机",
                    "展机", "样机", "激活机", "拆封机", "非全新", "准新"]

class NonNewTitleValidator:
    def validate(self, item: SecondHandItem) -> List[str]:
        issues = []
        title = item.title.strip()
        # 1. 标题最前方必须有非全新属性词
        if not any(title.startswith(p) for p in NON_NEW_PREFIXES):
            issues.append("标题未以非全新属性词开头(二手/95新/官翻机/激活机...)")
        # 2. 标题成色词 与 condition_text 一致
        if item.condition_text and item.condition_text not in title:
            issues.append(f"标题未包含成色'{item.condition_text}'")
        # 3. 详情必须描述非全新信息
        if item.non_new_prefix and item.non_new_prefix not in (item.title + item.detail_desc):
            issues.append("详情页未如实描述非全新信息")
        # 4. 官翻/资源/激活 必须显式声明
        if item.is_official_refurb and "官翻" not in title and "官翻" not in item.detail_desc:
            issues.append("官翻机未在标题/详情声明")
        if item.is_activated and "激活" not in title and "激活" not in item.detail_desc:
            issues.append("已激活机器未声明'激活机'")
        return issues

# ==================== 京东POP Mapper ====================
class JdPopMapper:
    """统一 SecondHandItem <-> 京东 ware/sku/props 结构"""

    def to_jd_payload(self, item: SecondHandItem) -> Dict:
        resolver = JdConditionResolver()
        item = resolver.resolve(item)
        validator = NonNewTitleValidator()
        issues = validator.validate(item)
        if issues:
            raise JdValidationError(issues)

        props = []
        if item.jd_attr_condition_id and item.jd_condition_value_id:
            props.append({"attrId": item.jd_attr_condition_id,
                          "attrValues": str(item.jd_condition_value_id)})
        for k, v in item.sku_props.items():
            props.append({"attrName": k, "attrValue": v})

        ware = {
            "title": item.title,
            "categoryId": item.jd_category_id,
            "brandId": item.brand_id,
            "outerId": item.outer_id,
            "marketPrice": item.market_price,
            "introduction": item.detail_desc,
            "props": props,
        }
        sku = {
            "outerId": item.outer_id,
            "jdPrice": item.jd_price,
            "stockNum": item.stock,
            "props": [{"attrId": p["attrId"], "attrValue": p["attrValues"]}
                      for p in props if "attrId" in p],
        }
        return {
            "method": "jingdong.ware.write.add",
            "ware": ware,
            "sku": sku,
            "non_new_prefix": item.non_new_prefix,
        }

    def from_jd_ware(self, ware: Dict, sku: Dict) -> SecondHandItem:
        """京东读回 -> 统一模型"""
        props = {p.get("attrId"): p.get("attrValues") for p in ware.get("props", [])}
        # 反向查成色: 遍历类目表
        cond_text = ""
        for cate, (attr_id, values) in JdConditionResolver.CATE_CONDITION.items():
            if str(props.get(attr_id)) in {str(v) for v in values.values()}:
                cond_text = [k for k, v in values.items()
                             if str(v) == str(props.get(attr_id))][0]
                break
        return SecondHandItem(
            master_sku=str(ware.get("outerId") or sku.get("outerId") or ""),
            platform="jd_pop",
            title=ware.get("title", ""),
            condition_text=cond_text,
            condition_int=ConditionIntMapper().to_int(cond_text),
            jd_category_id=int(ware.get("categoryId", 0)),
            jd_price=float(sku.get("jdPrice", 0)),
            market_price=float(ware.get("marketPrice", 0)),
            stock=int(sku.get("stockNum", 0)),
            outer_id=str(ware.get("outerId") or ""),
            detail_desc=ware.get("introduction", ""),
        )
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex 注册链接
# ==================== 订单侧: 京东POP订单 -> 统一订单 ====================
@dataclass
class UnifiedOrder:
    order_id: str
    platform: str
    status: str
    total: float
    sku_items: List[Dict]
    raw: Dict = field(default_factory=dict)
    # 二手扩展
    is_second_hand: bool = False
    quality_report_id: Optional[str] = None
    consignment: bool = False        # 寄卖
    mid_inspect: bool = False         # 售中质检

class JdPopOrderNormalizer:
    """把京东B2C订单规整为统一订单, 并标记二手履约维度"""
    def normalize(self, jd_order: Dict) -> UnifiedOrder:
        sku_items = jd_order.get("skuList", jd_order.get("itemInfoList", []))
        is_2nd = any(
            str(i.get("categoryName", "")).startswith("非全新")
            or i.get("nonNew") or i.get("isSecondHand")
            for i in sku_items)
        return UnifiedOrder(
            order_id=str(jd_order.get("orderId") or jd_order.get("order_id")),
            platform="jd_pop",
            status=jd_order.get("orderState") or jd_order.get("status"),
            total=float(jd_order.get("orderTotalPrice", 0)),
            sku_items=sku_items,
            raw=jd_order,
            is_second_hand=is_2nd,
            quality_report_id=jd_order.get("qualityReportId"),
            consignment=jd_order.get("consignment", False),
            mid_inspect=jd_order.get("midInspect", False),
        )
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex 注册链接
# ==================== 异常 ====================
class JdMappingError(Exception): pass
class JdValidationError(Exception):
    def __init__(self, issues: List[str]):
        self.issues = issues
        super().__init__("; ".join(issues))

# ==================== 演示 ====================
if __name__ == "__main__":
    mapper = JdPopMapper()

    print("=== 案例1: 95新 iPhone 挂京东POP ===")
    item = SecondHandItem(
        master_sku="MSKU-IP13-128-BLK-95",
        platform="jd_pop",
        title="95新 Apple iPhone 13 128G 黑色 国行",
        non_new_prefix="95新",
        condition_int=95, condition_text="95新",
        jd_category_id=15119, brand_id=4740,
        sku_props={"颜色": "黑色", "容量": "128GB"},
        jd_price=3299.0, market_price=5999.0, stock=3,
        outer_id="MSKU-IP13-128-BLK-95",
        is_activated=True,
        detail_desc="95新: 细微使用痕迹, 已激活, 功能正常, 电池健康92%")
    try:
        payload = mapper.to_jd_payload(item)
        print(f"  ware.title={payload['ware']['title']}")
        print(f"  props={payload['ware']['props']}")
        print(f"  sku.stock={payload['sku']['stockNum']}")
    except JdValidationError as e:
        print("  校验失败:", e.issues)

    print("\n=== 案例2: 标题没写非全新前缀 → 拦截 ===")
    bad = SecondHandItem(master_sku="x", title="Apple iPhone 13 128G 黑色",
                         condition_text="95新", jd_category_id=15119)
    try:
        mapper.to_jd_payload(bad)
    except JdValidationError as e:
        print("  🚫", e.issues)

    print("\n=== 案例3: 成色不在该类目枚举 → 拦截 ===")
    bad2 = SecondHandItem(master_sku="x", title="二手 iPhone",
                          condition_text="88新", jd_category_id=15119)
    try:
        mapper.to_jd_payload(bad2)
    except JdMappingError as e:
        print("  🚫", e)
    except JdValidationError as e:
        print("  🚫 校验:", e.issues)

    print("\n=== 案例4: 京东订单 -> 统一订单(二手履约标记) ===")
    jd_order = {
        "orderId": "JD20260908XXX",
        "orderState": "PAID",
        "orderTotalPrice": "3299.00",
        "qualityReportId": "AHS20260908",
        "consignment": True,
        "midInspect": True,
        "skuList": [{"categoryName": "非全新", "nonNew": True, "skuId": 123}],
    }
    uo = JdPopOrderNormalizer().normalize(jd_order)
    print(f"  order={uo.order_id} 二手={uo.is_second_hand} "
          f"寄卖={uo.consignment} 售中质检={uo.mid_inspect} 质检单={uo.quality_report_id}")

    print("\n=== 案例5: 读回京东商品 -> 统一模型 ===")
    back = mapper.from_jd_ware(
        {"title": "95新 Apple iPhone 13", "categoryId": 15119,
         "outerId": "MSKU-IP13", "marketPrice": 5999,
         "introduction": "95新细节", "props": [{"attrId": 90001, "attrValues": "95001"}]},
        {"jdPrice": 3299, "stockNum": 3, "outerId": "MSKU-IP13"})
    print(f"  condition_text={back.condition_text} int={back.condition_int} "
          f"price={back.jd_price} stock={back.stock}")
跑出来关键几行(模型转换实证):
=== 案例1: 95新 iPhone 挂京东POP ===
  ware.title=95新 Apple iPhone 13 128G 黑色 国行
  props=[{'attrId': 90001, 'attrValues': 95001}, {'attrName':'颜色',...}]
  sku.stock=3

=== 案例2: 标题没写非全新前缀 → 拦截 ===
  🚫 ['标题未以非全新属性词开头(二手/95新/官翻机/激活机...)', '标题未包含成色\'95新\'']

=== 案例3: 成色不在该类目枚举 → 拦截 ===
  🚫 类目15119不支持成色'88新', 可选:['99新','95新','9新','8新','官翻机',...]

=== 案例4: 京东订单 -> 统一订单(二手履约标记) ===
  order=JD20260908XXX 二手=True 寄卖=True 售中质检=True 质检单=AHS20260908

=== 案例5: 读回京东商品 -> 统一模型 ===
  condition_text=95新 int=95 price=3299.0 stock=3

五、五个京东二手对接铁律

  1. 成色不是字符串常量,是“类目×属性×值”三元组:写死 condition="95新" 换类目就挂;必须 categoryId → attrId → valueId 动态解析(生产调 findAttrsByCategoryIdUnlimitCate + findValuesByAttrIdUnlimit)。

  2. 标题前缀是合规不是SEO:京东非全新规则要求标题最前方加“二手/95新/官翻机/资源机/激活机/拆封机”,详情页还要一致描述——三者不一致=违规下架。

  3. SKU由销售属性组合决定,不是skuId决定saveWareSkus/updateSkus 里销售属性值组合一变就新增/删除SKU,二手“每件一成色”场景要把成色塞进销售属性维度,否则库存串货。

  4. 京东订单≠闲鱼订单:售后走AFS、寄卖走拍拍签约、渠道订单有支付/取消通知、二手还有售中质检——统一 UnifiedOrder 必须带 is_second_hand/quality_report_id/consignment/mid_inspect 扩展位,别把京东订单当C2C订单处理。

  5. outerId挂ware不挂sku语义:京东 outerId 是商品级外码,SKU级外码要自己在销售属性/商家编码里再挂一层;前篇 master_sku 主数据层要把 jd_ware_outer_id + jd_sku_outer_id 两个键都存。


六、和前几篇的衔接

JdPopMapper + JdConditionResolver 装进 commerce-mesh/adapters/jd_pop/
  • 主数据层MasterSku.listings 里京东侧存 {ware_outer_id, sku_outer_id, category_id, condition_attr_id}

  • 成色中枢:闲鱼 stuff_status int / 抖音枚举 / 转转 quality / 京东 category×attr×value 统一收敛到 condition_int,写回时各平台再展开;

  • 库存:京东POP走 jingdong.ware.stock.sku.set / stock.write.updateSkuStock(旧接口推下线),接入前篇 StockEngine 时把京东当成“B2C可写库存源”,但二手成色SKU要按销售属性分桶;

  • 订单JdPopOrderNormalizer 输出和闲鱼/抖音同构的 UnifiedOrder,前篇 OrderOrchestrator 不用改主流程,只加 consignment/mid_inspect 分支;

  • 合规:官翻/资源机/激活机标题声明接入 ComplianceGate 的内容合规维度,避免“全新当95新卖”的虚假宣传。
    京东POP二手对接的本质难点,是“用B2C货架系统表达C2C每件一态”——模型翻译做不对,商品能发上去也会违规下架;模型翻译做对了,九平台里最‘重’的这家反而最稳。

要不要我把 jd_pop_secondhand.py 合进 commerce-mesh/adapters/jd_pop/,并加一个 JdCategoryAttrCache(类目→成色attrId→valueId 本地YAML+接口预热),让九平台成色中枢支持“京东类目动态成色表”热更新?


群贤毕至

访客