mcpField
M
marketplace-fulfillment

marketplace-fulfillment

github.com/vtex开发471.0k 次安装2 周前更新
代码仓库

关于 marketplace-fulfillment

面向 VTEX 外部卖家的履约集成技能,涵盖模拟、订单预留与发货,以及通过 OMS 提交发票和物流跟踪。

主要功能

  1. 01实现履约模拟与物流选项响应
  2. 02创建订单预留并返回 orderId
  3. 03处理订单发货授权
  4. 04提交发票及拆包发票
  5. 05单独更新物流跟踪信息

适用场景

  1. 01接入 VTEX 商品索引和结账流程
  2. 02将市场订单转为卖家预留及正式订单
  3. 03为发货订单提交发票和跟踪信息

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/vtex/skills --skill marketplace-fulfillment

安装后在 Claude Code 中输入/marketplace-fulfillment确认已加载。

详细介绍

履约、模拟、订单与 OMS 后续处理

适用场景

在构建 外部卖家 集成时使用此技能:VTEX 会将库存可用性、配送、结账模拟和下单请求转发到你的履约基础 URL;发货后,你需要调用市场 OMS API 发送发票和跟踪信息。

  • 实现 POST /pvt/orderForms/simulation(索引和/或结账——有或没有客户上下文)
  • 实现 POST /pvt/orders(下单——创建预留;返回 orderId = 你系统中的预留 ID)
  • 处理 POST /pvt/orders/{sellerOrderId}/fulfill(订单获批后发货——路径使用你在下单时返回的同一个 orderId)
  • 通过 POST /api/oms/pvt/orders/{marketplaceOrderId}/invoice 发送发票通知(路径中使用市场订单 ID)
  • 通过 PATCH /api/oms/pvt/orders/{marketplaceOrderId}/invoice/{invoiceNumber} 更新跟踪信息
  • 实现拆分发货的部分开票

以下情况不要使用此技能:

  • 目录或 SKU 同步(参见 marketplace-catalog-sync)
  • 通过 Feed/Hook 消费订单事件(参见 marketplace-order-hook)
  • 通用 API 速率限制(参见 marketplace-rate-limiting)

决策规则

外部卖家协议(在卖家主机上实现)
  • 履约模拟 — POST /pvt/orderForms/simulation。VTEX 会在产品索引和结账期间调用此接口。请求可能仅包含 items(以及可选的查询参数),也可能包含完整的结账上下文:items、postalCode、country、clientProfileData、shippingData、selectedSla 等。没有邮政编码或客户资料(索引期间的常见情况)时,响应仍必须说明每个商品是否可用。提供完整上下文时,返回 items、logisticsInfo(每个请求商品对应一条记录)、postalCode、country,并按协议要求将 allowMultipleDeliveries 设为 true。items[].id 是卖家 SKU ID;items[].seller 是市场账户上的卖家 ID。
  • 响应结构 — 每条 logisticsInfo[] 记录都对应一个请求商品(itemIndex)。包含所有配送选项的 slas[](适用时包括送货上门和自提点)、含各渠道库存的 deliveryChannels[]、shipsTo 和 stockBalance。SLA 字段包括 price(每件商品的配送费,单位为分)、shippingEstimate / shippingEstimateDate(例如 5bd、30m)。自提 SLA 必须包含 pickupStoreInfo(地址、friendlyName 等)。
  • SLA — 模拟处理程序必须在 2.5 秒内响应,否则该商品将被视为不可用。
  • 下单 — 使用 JSON 数组形式的订单调用 POST /pvt/orders。为每个订单在你的系统中创建一个预留。响应必须与请求结构相同,并为每个订单添加 orderId:该值是你的预留 ID,之后协议调用中的 sellerOrderId 都会使用此值(例如授权履约的路径参数)。
  • 订单发货(授权履约) — 市场批准后,VTEX 会调用 POST /pvt/orders/{sellerOrderId}/fulfill,其中 sellerOrderId 等于你在下单时返回的 orderId。请求正文包含 marketplaceOrderId 和 marketplaceOrderGroup。在你的系统中将预留转为正式订单;响应正文包含 date、marketplaceOrderId、orderId(卖家引用)和 receipt。
OMS API(卖家 → 市场)
  • 通过 POST /api/oms/pvt/orders/{marketplaceOrderId}/invoice 发送发票。必填字段:type、invoiceNumber、invoiceValue(以分为单位)、issuanceDate 和 items 数组。路径中的 marketplaceOrderId 是 VTEX 市场订单 ID,而不是你的预留 ID。
  • 销售发票(发货)使用 type: "Output";退货发票使用 type: "Input"。
  • 承运商提供物流跟踪信息后,使用 PATCH /api/oms/pvt/orders/{marketplaceOrderId}/invoice/{invoiceNumber} 单独发送。
  • 对于拆分发货,每个包裹发送 一张发票,且只包含该包裹中的商品。每张发票的 invoiceValue 必须仅反映其中商品的金额。
  • 订单开具发票后,必须先发送退货发票(type: "Input"),才能取消订单。
  • 履约模拟端点必须在 2.5 秒内响应,否则商品会被视为不可用。

架构 / 数据流(概览):

VTEX Checkout / indexation          External Seller                    VTEX OMS (marketplace)
       │                                   │                                   │
       │── POST /pvt/orderForms/simulation ▶│  Price, stock, SLAs               │
       │◀── 200 + items + logisticsInfo ───│                                   │
       │                                   │                                   │
       │── POST /pvt/orders (array) ───────▶│  Create reservation               │
       │◀── same + orderId (reservation) ─│                                   │
       │                                   │                                   │
       │── POST /pvt/orders/{id}/fulfill ──▶│  Commit / pick pack               │
       │◀── date, marketplaceOrderId, ... ─│                                   │
       │                                   │── POST .../invoice ────────────────▶│
       │                                   │── PATCH .../invoice/{n} ─────────▶│

硬性约束

约束:OMS 路径中的市场订单 ID

/api/oms/pvt/orders/{orderId}/... 中的任何 {orderId} 都必须是 VTEX 市场订单 ID(OMS),而不是你在 POST /pvt/orders 返回的 orderId(预留 ID)。调用发票或物流跟踪 API 之前,必须先从协议中(下单负载、履约请求体或事件)映射出 marketplaceOrderId。

原因

在 OMS URL 中使用预留 ID,无法匹配市场订单;发票和物流跟踪信息都不会关联到客户订单。

检测

如果 POST /pvt/orders 响应中的 orderId 与 POST .../oms/.../invoice 使用的是同一个变量,且未进行映射 → 停止。

正确

// reservationId from your POST /pvt/orders response; marketplaceOrderId from VTEX payload
await omsClient.post(`/api/oms/pvt/orders/${marketplaceOrderId}/invoice`, payload);

错误

await omsClient.post(`/api/oms/pvt/orders/${reservationId}/invoice`, payload);

约束:履约模拟契约和延迟

卖家必须实现 POST /pvt/orderForms/simulation,为每个请求返回有效的 items 数组。当请求包含结账上下文(例如 postalCode、clientProfileData、shippingData)时,响应必须包含匹配的 logisticsInfo、所有相关模式的 slas(包括提供自提服务时的自提选项),并在需要时设置 allowMultipleDeliveries: true。处理程序必须在 2.5 秒内完成。

原因

logisticsInfo 不完整或缺少 SLA 会导致结账时无法选择配送方式。响应缓慢会导致商品被标记为不可用,并降低转化率。

检测

如果模拟请求包含 shippingData,但响应仅返回带有价格的 items,省略了 logisticsInfo → 警告。如果未启用缓存且 p95 延迟接近 2 秒 → 警告。

正确

// Pseudocode: branch on whether checkout context is present
if (hasCheckoutContext(req.body)) {
  return res.json({
    country: req.body.country,
    items: pricedItems,
    logisticsInfo: buildLogisticsPerItem(pricedItems, req.body),
    postalCode: req.body.postalCode,
    allowMultipleDeliveries: true,
  });
}
return res.json({ items: availabilityOnlyItems /* + minimal logistics if required */ });

错误

// WRONG: Full checkout body but response omits logisticsInfo / SLAs
res.json({ items: pricedItemsOnly });

约束:下单必须返回卖家 orderId(预留 ID)

POST /pvt/orders 接受一个订单数组(JSON 数组)。响应必须返回相同的订单,并将每个元素中的 orderId 设置为你的预留标识符(卖家系统)。VTEX 会在 POST /pvt/orders/{sellerOrderId}/fulfill 中将该值用作 sellerOrderId。

原因

省略 orderId 或重复使用虚假的 orderId,会破坏市场订单流程与预留记录之间的关联,并导致派送无法正确路由。

检测

如果处理程序返回 200,但未添加 orderId,或返回单个对象而不是数组 → 警告。

正确

app.post("/pvt/orders", (req, res) => {
  const orders = req.body as Array<Record<string, unknown>>;
  const out = orders.map((order) => {
    const reservationId = createReservation(order);
    return { ...order, orderId: reservationId, followUpEmail: "" };
  });
  res.json(out);
});

错误

app.post("/pvt/orders", (req, res) => {
  createReservation(req.body);
  res.status(200).send(); // WRONG: missing orderId echo
});

约束:发送包含所有必填字段的正确格式发票

发票通知必须包含 type、invoiceNumber、invoiceValue、issuanceDate 和 items 数组。invoiceValue 必须以分为单位。items 数组必须与订单中的商品一致。

为什么这很重要

缺少必填字段会导致 API 以 400 Bad Request 拒绝发票,使订单卡在“处理中”状态。invoiceValue 不正确(例如使用美元而非分)会导致市场平台对账时出现财务差异。

检测

如果看到发票通知载荷缺少 invoiceNumber、invoiceValue、issuanceDate 或 items → 提醒缺少必填字段。如果 invoiceValue 看起来是以美元为单位(例如,99.90 而不是 9990)→ 提醒进行分的换算。

正确

import axios, { AxiosInstance } from "axios";

interface InvoiceItem {
  id: string;
  quantity: number;
  price: number; // in cents
}

interface InvoicePayload {
  type: "Output" | "Input";
  invoiceNumber: string;
  invoiceValue: number; // total in cents
  issuanceDate: string; // ISO 8601
  invoiceUrl?: string;
  invoiceKey?: string;
  courier?: string;
  trackingNumber?: string;
  trackingUrl?: string;
  items: InvoiceItem[];
}

async function sendInvoiceNotification(
  client: AxiosInstance,
  marketplaceOrderId: string,
  invoice: InvoicePayload
): Promise<void> {
  // Validate required fields before sending
  if (!invoice.invoiceNumber) {
    throw new Error("invoiceNumber is required");
  }
  if (!invoice.invoiceValue || invoice.invoiceValue <= 0) {
    throw new Error("invoiceValue must be a positive number in cents");
  }
  if (!invoice.issuanceDate) {
    throw new Error("issuanceDate is required");
  }
  if (!invoice.items || invoice.items.length === 0) {
    throw new Error("items array is required and must not be empty");
  }

  // Warn if invoiceValue looks like it's in dollars instead of cents
  if (invoice.invoiceValue < 100 && invoice.items.length > 0) {
    console.warn(
      `Warning: invoiceValue ${invoice.invoiceValue} seems very low. ` +
        `Ensure it's in cents (e.g., 9990 for $99.90).`
    );
  }

  await client.post(`/api/oms/pvt/orders/${marketplaceOrderId}/invoice`, invoice);
}

// Example usage:
async function invoiceOrder(
  client: AxiosInstance,
  marketplaceOrderId: string
): Promise<void> {
  await sendInvoiceNotification(client, marketplaceOrderId, {
    type: "Output",
    invoiceNumber: "NFE-2026-001234",
    invoiceValue: 15990, // $159.90 in cents
    issuanceDate: new Date().toISOString(),
    invoiceUrl: "https://invoices.example.com/NFE-2026-001234.pdf",
    invoiceKey: "35260614388220000199550010000012341000012348",
    items: [
      { id: "123", quantity: 1, price: 9990 },
      { id: "456", quantity: 2, price: 3000 },
    ],
  });
}

错误

// WRONG: Missing required fields, value in dollars instead of cents
async function sendBrokenInvoice(
  client: AxiosInstance,
  marketplaceOrderId: string
): Promise<void> {
  await client.post(`/api/oms/pvt/orders/${marketplaceOrderId}/invoice`, {
    // Missing 'type' field — API may reject or default incorrectly
    invoiceNumber: "001234",
    invoiceValue: 159.9, // WRONG: dollars, not cents — causes financial mismatch
    // Missing 'issuanceDate' — API will reject with 400
    // Missing 'items' — API cannot match invoice to order items
  });
}

约束:发货后及时更新追踪信息

承运商提供追踪信息后,必须立即发送。使用 PATCH /api/oms/pvt/orders/{marketplaceOrderId}/invoice/{invoiceNumber} 将追踪信息添加到现有发票。

为什么这很重要

延迟更新追踪信息会导致客户无法在市场平台上查看配送状态。订单会停留在“已开票”状态,而无法继续进入“配送中”状态,随后也无法进入“已送达”状态。这会产生客户支持工单,并损害卖家的声誉。

检测

如果看到追踪信息被集中起来每天更新,而不是实时发送 → 提醒及时更新追踪信息。如果在承运商尚未提供追踪信息时(使用硬编码/空值)将其包含在初始发票请求中 → 提醒。

正确

interface TrackingUpdate {
  courier: string;
  trackingNumber: string;
  trackingUrl?: string;
  isDelivered?: boolean;
}

async function updateOrderTracking(
  client: AxiosInstance,
  marketplaceOrderId: string,
  invoiceNumber: string,
  tracking: TrackingUpdate
): Promise<void> {
  await client.patch(
    `/api/oms/pvt/orders/${marketplaceOrderId}/invoice/${invoiceNumber}`,
    tracking
  );
}

// Send tracking as soon as carrier provides it
async function onCarrierPickup(
  client: AxiosInstance,
  marketplaceOrderId: string,
  invoiceNumber: string,
  carrierData: { name: string; trackingId: string; trackingUrl: string }
): Promise<void> {
  await updateOrderTracking(client, marketplaceOrderId, invoiceNumber, {
    courier: carrierData.name,
    trackingNumber: carrierData.trackingId,
    trackingUrl: carrierData.trackingUrl,
  });
  console.log(
    `Tracking updated for marketplace order ${marketplaceOrderId}: ${carrierData.trackingId}`
  );
}

// Update delivery status when confirmed
async function onDeliveryConfirmed(
  client: AxiosInstance,
  marketplaceOrderId: string,
  invoiceNumber: string
): Promise<void> {
  await updateOrderTracking(client, marketplaceOrderId, invoiceNumber, {
    courier: "",
    trackingNumber: "",
    isDelivered: true,
  });
  console.log(`Marketplace order ${marketplaceOrderId} marked as delivered`);
}

错误

// WRONG: Sending empty/fake tracking data with the invoice
async function invoiceWithFakeTracking(
  client: AxiosInstance,
  marketplaceOrderId: string
): Promise<void> {
  await client.post(`/api/oms/pvt/orders/${marketplaceOrderId}/invoice`, {
    type: "Output",
    invoiceNumber: "NFE-001",
    invoiceValue: 9990,
    issuanceDate: new Date().toISOString(),
    items: [{ id: "123", quantity: 1, price: 9990 }],
    // WRONG: Hardcoded tracking — carrier hasn't picked up yet
    courier: "TBD",
    trackingNumber: "PENDING",
    trackingUrl: "",
  });
  // Customer sees "PENDING" as tracking number — useless information
}

约束:处理拆分发货的部分开票

对于分多个包裹发货的订单,每个包裹的货件都必须有单独的发票,且仅包含该包裹中的商品。invoiceValue 必须仅反映该货件中的商品价值。

为什么这很重要

仅发出部分商品时,却为整个订单价值发送单张发票,会导致财务差异。市场平台无法正确对账,订单状态也可能无法正常推进。

检测

如果看到部分发货时仍发送包含整个订单价值的单张发票 → 提醒需要部分开票。如果 items 数组与实际发货商品不一致 → 提醒。

正确

interface OrderItem {
  id: string;
  name: string;
  quantity: number;
  price: number; // per unit in cents
}

interface Shipment {
  items: OrderItem[];
  invoiceNumber: string;
}

async function sendPartialInvoices(
  client: AxiosInstance,
  marketplaceOrderId: string,
  shipments: Shipment[]
): Promise<void> {
  for (const shipment of shipments) {
    // Calculate value for only the items in this shipment
    const shipmentValue = shipment.items.reduce(
      (total, item) => total + item.price * item.quantity,
      0
    );

    await sendInvoiceNotification(client, marketplaceOrderId, {
      type: "Output",
      invoiceNumber: shipment.invoiceNumber,
      invoiceValue: shipmentValue,
      issuanceDate: new Date().toISOString(),
      items: shipment.items.map((item) => ({
        id: item.id,
        quantity: item.quantity,
        price: item.price,
      })),
    });

    console.log(
      `Partial invoice ${shipment.invoiceNumber} sent for marketplace order ${marketplaceOrderId}: ` +
        `${shipment.items.length} items, value=${shipmentValue}`
    );
  }
}

// Example: Order with 3 items shipped in 2 packages
await sendPartialInvoices(client, "vtex-marketplace-order-id-12345", [
  {
    invoiceNumber: "NFE-001-A",
    items: [
      { id: "sku-1", name: "Laptop", quantity: 1, price: 250000 },
    ],
  },
  {
    invoiceNumber: "NFE-001-B",
    items: [
      { id: "sku-2", name: "Mouse", quantity: 1, price: 5000 },
      { id: "sku-3", name: "Keyboard", quantity: 1, price: 12000 },
    ],
  },
]);

错误

// WRONG: Sending full order value for partial shipment
async function wrongPartialInvoice(
  client: AxiosInstance,
  marketplaceOrderId: string,
  totalOrderValue: number,
  shippedItems: OrderItem[]
): Promise<void> {
  await client.post(`/api/oms/pvt/orders/${marketplaceOrderId}/invoice`, {
    type: "Output",
    invoiceNumber: "NFE-001-A",
    invoiceValue: totalOrderValue, // WRONG: Full order value, not partial
    issuanceDate: new Date().toISOString(),
    items: shippedItems.map((item) => ({
      id: item.id,
      quantity: item.quantity,
      price: item.price,
    })),
    // invoiceValue doesn't match sum of items — financial mismatch
  });
}

首选模式

实现履约模拟

注册 POST /pvt/orderForms/simulation。解析 items[](id = 卖家 SKU,seller = 市场平台上的卖家 ID)。如果请求正文中没有 postalCode / clientProfileData,则将其视为索引请求:返回可用性(如果你的合同要求,也返回最少的物流信息)。如果请求正文包含结账字段,则按 itemIndex 构建 logisticsInfo,填充 slas(配送和到店自提;适用时包含 pickupStoreInfo)、deliveryChannels、stockBalance,设置 country / postalCode,并设置 allowMultipleDeliveries: true。限制 CPU 和 I/O 的使用,确保处理时间低于 2.5 秒。

import { RequestHandler } from "express";

const fulfillmentSimulation: RequestHandler = async (req, res) => {
  const body = req.body as Record<string, unknown>;
  const items = body.items as Array<{ id: string; quantity: number; seller: string }>;
  const hasCheckoutContext = Boolean(body.postalCode && body.shippingData);

  const pricedItems = await priceAndStockForItems(items);

  if (!hasCheckoutContext) {
    res.json({ items: pricedItems, logisticsInfo: minimalLogistics(pricedItems) });
    return;
  }

  res.json({
    country: body.country,
    postalCode: body.postalCode,
    items: pricedItems,
    logisticsInfo: buildFullLogistics(pricedItems, body),
    allowMultipleDeliveries: true,
  });
};

function minimalLogistics(
  pricedItems: Array<{ id: string; requestIndex: number }>
): unknown[] {
  return pricedItems.map((_, i) => ({
    itemIndex: i,
    quantity: 1,
    shipsTo: ["USA"],
    slas: [],
    stockBalance: "",
    deliveryChannels: [{ id: "delivery", stockBalance: "" }],
  }));
}

function buildFullLogistics(
  pricedItems: Array<{ id: string; requestIndex: number }>,
  _checkoutBody: unknown
): unknown[] {
  // Replace with SLA / carrier rules derived from checkoutBody
  return pricedItems.map((_, i) => ({
    itemIndex: i,
    quantity: 1,
    shipsTo: ["USA"],
    slas: [],
    stockBalance: 0,
    deliveryChannels: [],
  }));
}

async function priceAndStockForItems(
  items: Array<{ id: string; quantity: number; seller: string }>
): Promise<Array<Record<string, unknown>>> {
  return items.map((item, requestIndex) => ({
    id: item.id,
    quantity: item.quantity,
    seller: item.seller,
    measurementUnit: "un",
    merchantName: null,
    price: 0,
    priceTags: [],
    priceValidUntil: null,
    requestIndex,
    unitMultiplier: 1,
    attachmentOfferings: [],
  }));
}
实现下单(预留)

注册 POST /pvt/orders。请求正文是一个数组。将每个订单保存为预留;响应中返回相同的对象,并添加 orderId(预留键),通常还包括 followUpEmail。VTEX 会将该 orderId 作为 sellerOrderId 传入 POST /pvt/orders/{sellerOrderId}/fulfill。

import { RequestHandler } from "express";

function createReservation(_order: Record<string, unknown>): string {
  return `res-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}

const orderPlacement: RequestHandler = (req, res) => {
  const orders = req.body as Array<Record<string, unknown>>;
  const out = orders.map((order) => ({
    ...order,
    orderId: createReservation(order),
    followUpEmail: "",
  }));
  res.json(out);
};
实现授权履约端点

支付获批后,市场平台会调用此端点。

import express, { RequestHandler } from "express";

interface FulfillOrderRequest {
  marketplaceOrderId: string;
  marketplaceOrderGroup: string;
}

interface OrderMapping {
  /** Same id returned as orderId from POST /pvt/orders (reservation) */
  sellerOrderId: string;
  marketplaceOrderId: string;
  items: OrderItem[];
  status: string;
}

// Store for order mappings — use a real database in production
const orderStore = new Map<string, OrderMapping>();

const authorizeFulfillmentHandler: RequestHandler = async (req, res) => {
  const sellerOrderId = req.params.sellerOrderId;
  const { marketplaceOrderId, marketplaceOrderGroup }: FulfillOrderRequest = req.body;

  console.log(
    `Fulfillment authorized: reservation=${sellerOrderId}, marketplaceOrder=${marketplaceOrderId}, group=${marketplaceOrderGroup}`
  );

  // Store the marketplace order ID mapping
  const order = orderStore.get(sellerOrderId);
  if (!order) {
    res.status(404).json({ error: "Order not found" });
    return;
  }

  order.marketplaceOrderId = marketplaceOrderId;
  order.status = "fulfillment_authorized";
  orderStore.set(sellerOrderId, order);

  // Trigger fulfillment process asynchronously
  enqueueFulfillment(sellerOrderId).catch(console.error);

  res.status(200).json({
    date: new Date().toISOString(),
    marketplaceOrderId,
    // Echo seller reservation id (same as path param / placement orderId)
    orderId: sellerOrderId,
    receipt: null,
  });
};

async function enqueueFulfillment(sellerOrderId: string): Promise<void> {
  console.log(`Enqueued fulfillment for ${sellerOrderId}`);
}

const app = express();
app.use(express.json());
app.post("/pvt/orders/:sellerOrderId/fulfill", authorizeFulfillmentHandler);
履约后发送发票

订单打包完成并生成发票后,发送发票通知。

async function fulfillAndInvoice(
  client: AxiosInstance,
  order: OrderMapping
): Promise<void> {
  // Generate invoice from your invoicing system
  const invoice = await generateInvoice(order);

  // Send invoice notification to VTEX marketplace
  await sendInvoiceNotification(client, order.marketplaceOrderId, {
    type: "Output",
    invoiceNumber: invoice.number,
    invoiceValue: invoice.totalCents,
    issuanceDate: invoice.issuedAt.toISOString(),
    invoiceUrl: invoice.pdfUrl,
    invoiceKey: invoice.accessKey,
    items: order.items.map((item) => ({
      id: item.id,
      quantity: item.quantity,
      price: item.price,
    })),
  });

  console.log(
    `Invoice ${invoice.number} sent for order ${order.marketplaceOrderId}`
  );
}

async function generateInvoice(order: OrderMapping): Promise<{
  number: string;
  totalCents: number;
  issuedAt: Date;
  pdfUrl: string;
  accessKey: string;
}> {
  const totalCents = order.items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
  return {
    number: `NFE-${Date.now()}`,
    totalCents,
    issuedAt: new Date(),
    pdfUrl: `https://invoices.example.com/NFE-${Date.now()}.pdf`,
    accessKey: "35260614388220000199550010000012341000012348",
  };
}
承运商取件时发送追踪信息
async function handleCarrierPickup(
  client: AxiosInstance,
  marketplaceOrderId: string,
  invoiceNumber: string,
  carrier: { name: string; trackingId: string; trackingUrl: string }
): Promise<void> {
  await updateOrderTracking(client, marketplaceOrderId, invoiceNumber, {
    courier: carrier.name,
    trackingNumber: carrier.trackingId,
    trackingUrl: carrier.trackingUrl,
  });

  console.log(
    `Tracking ${carrier.trackingId} sent for marketplace order ${marketplaceOrderId}`
  );
}
确认送达
async function handleDeliveryConfirmation(
  client: AxiosInstance,
  marketplaceOrderId: string,
  invoiceNumber: string
): Promise<void> {
  await client.patch(
    `/api/oms/pvt/orders/${marketplaceOrderId}/invoice/${invoiceNumber}`,
    {
      isDelivered: true,
      courier: "",
      trackingNumber: "",
    }
  );

  console.log(`Marketplace order ${marketplaceOrderId} marked as delivered`);
}
完整示例
import axios, { AxiosInstance } from "axios";

function createMarketplaceClient(
  accountName: string,
  appKey: string,
  appToken: string
): AxiosInstance {
  return axios.create({
    baseURL: `https://${accountName}.vtexcommercestable.com.br`,
    headers: {
      "Content-Type": "application/json",
      "X-VTEX-API-AppKey": appKey,
      "X-VTEX-API-AppToken": appToken,
    },
    timeout: 10000,
  });
}

async function completeFulfillmentFlow(
  client: AxiosInstance,
  order: OrderMapping
): Promise<void> {
  // 1. Fulfill and invoice
  await fulfillAndInvoice(client, order);

  // 2. When carrier picks up, send tracking
  const carrierData = await waitForCarrierPickup(order.sellerOrderId);
  const invoice = await getLatestInvoice(order.sellerOrderId);

  await handleCarrierPickup(
    client,
    order.marketplaceOrderId,
    invoice.number,
    carrierData
  );

  // 3. When delivered, confirm
  await waitForDeliveryConfirmation(order.sellerOrderId);
  await handleDeliveryConfirmation(
    client,
    order.marketplaceOrderId,
    invoice.number
  );
}

async function waitForCarrierPickup(
  sellerOrderId: string
): Promise<{ name: string; trackingId: string; trackingUrl: string }> {
  // Replace with actual carrier integration
  return {
    name: "Correios",
    trackingId: "BR123456789",
    trackingUrl: "https://tracking.example.com/BR123456789",
  };
}

async function getLatestInvoice(
  sellerOrderId: string
): Promise<{ number: string }> {
  // Replace with actual invoice lookup
  return { number: `NFE-${sellerOrderId}` };
}

async function waitForDeliveryConfirmation(
  sellerOrderId: string
): Promise<void> {
  // Replace with actual delivery confirmation logic
  console.log(`Waiting for delivery confirmation: ${sellerOrderId}`);
}

常见故障模式

  • 将索引模拟当作结账处理。 同一端点在索引期间接收最简请求体(不含客户位置),在结账期间接收完整请求体。对于最简请求返回结账级别的 logisticsInfo 可能是多余的工作;对于结账请求仅返回价格而不包含 SLA 和 logisticsInfo,则会导致配送方式选择失败。

  • 下单时遗漏 orderId。 VTEX 期望响应为一个数组,其中回显的每笔订单都设置了**orderId**,其值为你的预留订单 ID。返回空的 200 响应或缺少 orderId,都会导致订单流程停滞。

  • 在 OMS 发票 URL 中使用预留订单 orderId。 下单后,调用 /api/oms/pvt/orders/... 时必须使用协议中的 marketplaceOrderId。混淆这两个 ID 会导致 404 或发票/跟踪信息提交静默失败。

  • 在履约授权前发送发票。 卖家在订单下达后立即发送发票通知,而此时尚未收到来自市场的 Authorize Fulfillment 回调。付款可能仍处于待处理或审核中。授权前开具发票可能导致发票被拒收,或订单处于不一致状态。只有收到 POST /pvt/orders/{sellerOrderId}/fulfill 后才能发送发票。

  • 未处理取消订单的退货发票。 卖家尝试直接调用 Cancel Order 端点取消已开具发票的订单,却没有先发送退货发票。订单一旦处于“已开票”状态,若没有退货发票(type: "Input"),便无法取消。Cancel Order API 会拒绝该请求。

// Correct: Send return invoice before canceling an invoiced order
async function cancelInvoicedOrder(
  client: AxiosInstance,
  marketplaceOrderId: string,
  originalItems: InvoiceItem[],
  originalInvoiceValue: number
): Promise<void> {
  // Step 1: Send return invoice (type: "Input")
  await sendInvoiceNotification(client, marketplaceOrderId, {
    type: "Input", // Return invoice
    invoiceNumber: `RET-${Date.now()}`,
    invoiceValue: originalInvoiceValue,
    issuanceDate: new Date().toISOString(),
    items: originalItems,
  });

  // Step 2: Now cancel the order
  await client.post(
    `/api/marketplace/pvt/orders/${marketplaceOrderId}/cancel`,
    { reason: "Customer requested return" }
  );
}
  • 履约模拟超过 2.5 秒超时。 卖家的履约模拟端点执行复杂的数据库查询或外部 API 调用,导致响应时间超过限制。VTEX 市场最多等待 2.5 秒以获取履约模拟响应。超时后,该商品会被视为不可用/未激活,因此不会显示在店面或结账页面中。请预缓存价格和库存数据。

审核清单

  • POST /pvt/orderForms/simulation 是否同时处理最简请求(索引)和结账格式的请求,并在存在相关上下文时返回 logisticsInfo 和 slas?
  • POST /pvt/orders 是否接受数组,并为每笔订单返回 orderId(预留订单 ID)?
  • POST /pvt/orders/{sellerOrderId}/fulfill 是否读取 marketplaceOrderId 和 marketplaceOrderGroup,并将 sellerOrderId 与下单时的预留订单相匹配?
  • OMS /invoice 和 PATCH .../invoice 调用是否使用 marketplaceOrderId,而非预留订单 ID?
  • 卖家是否只在收到 Authorize Fulfillment 回调后才开始实际履约?
  • 对于 IO/BFF 连接器:缓存和路由选择是否符合 vtex-io 技能中的要求(模拟 SLA、数据范围)?
  • 发票请求体是否包含所有必填字段(type、invoiceNumber、invoiceValue、issuanceDate、items)?
  • invoiceValue 的单位是否为分(而不是美元)?
  • 是否在承运商提供真实数据后单独发送跟踪信息(而非使用硬编码的占位内容)?
  • 对于拆分发货,每张发票是否仅包含对应包裹的商品和金额?
  • 已开票订单的取消是否先通过退货发票(type: "Input")处理?
  • 履约模拟端点是否能在 2.5 秒内响应?

参考资料

VTEX 还在 vtex-apps/external-seller-example GitHub 仓库中维护着 External Seller 服务的开源参考实现(可用作脚手架;请使其行为与上述官方协议文档保持一致)。

评论

支持 Markdown