# 结账页增删订单商品行

> 用 Shoplazza 结账扩展和 CheckoutAPI 让买家在结账页增删运费险等增值服务商品，并处理金额变化。

## 背景

运输保障险这类增值服务，目前只能挂载在购物车页展示与勾选，这一限制导致三个问题无法解决：

- 顾客点「立即购买」直接进入结账页时，全程看不到增值服务，损失了曝光；
- 在购物车页没有勾选的顾客，进入结账页后再没有第二次加购机会；
- 已勾选的顾客想取消，只能中断结账、退回购物车操作，容易造成弃单。

问题的根源都在于增值服务与购物车页强绑定、无法在结账页触达。因此本方案的核心目标是：将增值服务的展示与开关能力前移至结账页，使顾客在付款前的任意时刻都能自由加购或取消，从根本上解决曝光缺失、加购机会单次、取消成本高问题。

## 工作原理

1. **初始化**：顾客进入结账页，checkout 扩展把开关 UI 注入插槽，并轮询等待 `CheckoutAPI` 挂载和开关 DOM 就绪（两者都是页面加载后异步完成的）。
2. **首次计算保费**：用 `getProductList()` 读取订单商品行，排除增值服务行自身，算出应收费用，费用显示在开关旁。
3. **顾客操作开关**：每次开关变更，平台都自动整单重算价，返回最新数据后刷新开关状态与费用显示。

   1. 打开保费开关：调 `addLineItems` 把选中档位作为独立商品行加入订单，加行时在 `properties` 里带两个平台预定义标记——`_shoplazza_exclude_calculation: true`（该行不参与折扣、运费、税费、附加费计算）、`_shoplazza_bundled_product: true`（捆绑商品，不能单独成单）；
   2. 关闭保费开关：调 `removeLineItems` 移除该行。
4. **更新保费**：之后任何价格变动（应用/取消折扣码、礼品卡，以及顾客的开关操作）都会触发 `onPricesChange`，checkout 扩展在回调里重算应收费用、与订单里增值服务行的实际档位比对——不一致就删旧行、换档位重新加入。

整体流程：

```mermaid
flowchart TD
    A[顾客进入结账页] --> B[扩展在插槽渲染开关]
    B --> C[等待 window.CheckoutAPI 挂载]
    C --> D[读商品行计算应收费用]
    D --> E{顾客拨动开关}
    E -- 开 --> F[addLineItems 带标记加行]
    E -- 关 --> G[removeLineItems 移除]
    F --> H[平台自动全局重算价]
    G --> H
    H --> I[刷新开关状态与费用显示]
    C --> J[onPricesChange 触发] --> K{应收费用与行价格一致?}
    K -- 否 --> L[删旧行 换档位加新行] --> H
    K -- 是 --> I
```

## 示例开发

下面带你完成一个完整需求：在结账页放一个增值服务开关——以运输保障险为例，顾客打开开关，保险费作为一行商品加入当前订单并跟随商品总价变动；关闭开关，这行商品被移除。

## 前置条件

- 一个 checkout extension 项目——见[创建 checkout extension](/docs/app/extensions/checkout/add-checkout-extension)。
- 增值服务商品已存在于商家店铺中，由你的 App 在商家安装时通过商品创建接口建好：一个商品、多个 variant 作为价格档位、设置为不需要物流、不跟踪库存。
- 各店铺的档位 `variantId` 由你的服务端在建品时记录，扩展启动时从你的服务端获取。
- 顾客不应能从店面直接购买该商品——直接购买产生的行不带任何标记，会按普通商品参与折扣、计税和发货。

## 第一步：渲染开关

用 `extend()` 把开关挂到插槽上。订单摘要类的增值服务用 `Checkout::Reductions::RenderAfter` 这个插槽，位置在优惠券输入框下方。本篇的全部代码都写在 `src/index.js` 一个文件里，UI 用模板字符串：

```javascript
import { extend } from 'shoplazza-extension-ui';

// 开关组件：默认隐藏，等 CheckoutAPI 就绪、算出费用后再显示
const template = `
<div id="protection-widget" style="display:none;padding:12px;border:1px solid #e5e7eb;border-radius:6px;">
  <label>
    <input type="checkbox" id="protection-toggle" />
    运输保障险 <span id="protection-fee"></span>
  </label>
</div>
`;

// 把开关 HTML 注入结账页"优惠券输入框下方"的插槽
extend({
  extensionPoint: 'Checkout::Reductions::RenderAfter',
  component: template,
});
```

## 第二步：等待 CheckoutAPI 和组件就绪

扩展脚本在页面加载早期就开始执行，此时有两样东西可能还不存在：`window.CheckoutAPI`（平台在页面完全加载后才挂载，见[加载时机说明](/docs/app/extensions/checkout/checkout-api)）和你自己的开关 DOM（`extend()` 注入插槽是异步的）。两者都就绪后再执行业务逻辑，用轮询等待：

```javascript
// 轮询等待两件事都就绪：CheckoutAPI 挂载 + 开关 DOM 被注入插槽
// （二者都在页面加载后异步完成，先后顺序不定）每 200ms 查一次，最多 50 次（约 10 秒）
function mount(onReady, retries = 50) {
  if (window.CheckoutAPI && document.getElementById('protection-widget')) {
    return onReady(window.CheckoutAPI);
  }
  if (retries <= 0) return;
  setTimeout(() => mount(onReady, retries - 1), 200);
}
```

## 第三步：计算应收费用

用 `CheckoutAPI.summary.getProductList()` 读当前订单的商品行，排除自己加的保险行，按剩余商品总价算出费用，再从价格档位中选出标价最接近的 variant。

```javascript
// 费率：应收费用 = 商品总价 × RATE，规则由你的业务决定
const RATE = 0.02;

// 价格档位：建品时创建的各 variant 及其标价
// 每家店铺建出的 variantId 不同，真实实现应从你的服务端拉取，这里为演示直接内联
const TIERS = [
  { variantId: 'tier-variant-id-050', price: 0.5 },
  { variantId: 'tier-variant-id-100', price: 1.0 },
  { variantId: 'tier-variant-id-200', price: 2.0 },
  { variantId: 'tier-variant-id-290', price: 2.9 },
];
const TIER_IDS = new Set(TIERS.map(t => t.variantId));

// 变更来源标识：addLineItems / removeLineItems 的必填参数，标明改动由谁发起
const SOURCE = 'shipping_protection';

// 判断某个商品行是不是自己加的保险行：看它的 variantId 是否属于档位集合
const isProtectionLine = item => TIER_IDS.has(item.variantId);

function expectedTier(api) {
  // 取订单全部商品行，排除保险行自身，得到"被保障的商品"
  const goods = api.summary.getProductList().filter(p => !isProtectionLine(p));
  // 金额字段是字符串（如 "68.00"），求和前转数字
  // finalLinePrice 是折后价，linePrice 是折前价——费率按哪个算是业务口径，示例用折后价
  const total = goods.reduce((sum, p) => sum + Number(p.finalLinePrice || p.linePrice), 0);
  const fee = total * RATE;
  // 选标价与应收费用差值最小的档位
  return TIERS.reduce((best, t) =>
    Math.abs(t.price - fee) < Math.abs(best.price - fee) ? t : best
  );
}
```

## 第四步：开关打开时加入商品行

调 `CheckoutAPI.order.addLineItems` 把选中档位的商品加进订单。参数说明：

- `mutationSource`：必填，来源标识字符串；
- `lineItems[].variantId`：要加入的商品规格 id，行价格取它的标价；
- `lineItems[].quantity`：数量；
- `lineItems[].properties`：直接传对象，不要自己 `JSON.stringify`；两个标记的作用见「工作原理」。

```javascript
async function addProtection(api) {
  // 按当前商品总价选出目标档位
  const tier = expectedTier(api);
  const res = await api.order.addLineItems({
    mutationSource: SOURCE,
    lineItems: [
      {
        variantId: tier.variantId,
        quantity: 1,
        properties: {
          _shoplazza_bundled_product: true,      // 不能单独成单
          _shoplazza_exclude_calculation: true,  // 不参与折扣/税/运费计算
        },
      },
    ],
  });
  return res;
}
```

方法返回 Promise 且不会 throw，用 `res.state === 'success'` 判断结果。成功时平台已自动完成整单重算价，返回值携带最新的商品行：

```json
{
  "state": "success",
  "message": "success",
  "errors": [],
  "data": {
    "orderId": "2446407205415840740905",
    "lineItems": [
      {
        "id": "18b3f672-8c94-42fc-80c6-8b68ba2b4ebf",
        "variantId": "tier-variant-id-290",
        "productTitle": "运输保障险",
        "price": "2.90",
        "linePrice": "2.90",
        "trunkPrice": "0.00",
        "quantity": 1,
        "requiresShipping": false,
        "properties": "{\"_shoplazza_bundled_product\":true,\"_shoplazza_exclude_calculation\":true}"
      }
    ]
  }
}
```

:::note
`properties` 传入时是对象，返回时是 JSON 字符串，读取前先 `JSON.parse`。商品行 `id` 在每次删除重加后都会变化，不要缓存，始终以 `data.lineItems` 里的最新值为准。
:::

## 第五步：开关关闭时移除商品行

调 `CheckoutAPI.order.removeLineItems`，传要移除的商品行 `id`（注意是行 id，不是 variantId）和来源标识。

```javascript
async function removeProtection(api) {
  // 从当前商品行里找到自己加的保险行，拿它的行 id
  const line = api.summary.getProductList().find(isProtectionLine);
  if (!line) return null;
  return api.order.removeLineItems({
    mutationSource: SOURCE,
    lineItemIds: [line.id],
  });
}
```

移除失败时，错误码在 `state` 和 `errors` 里，`data` 为 `null`：

```json
{
  "state": "bundled_product_requires_real_item",
  "message": "bundled_product_requires_real_item",
  "errors": ["bundled_product_requires_real_item"],
  "data": null
}
```

`bundled_product_requires_real_item` 表示这次移除会让订单只剩增值服务商品行（捆绑行）——平台以此保证增值服务不会被单独购买。

## 第六步：价格变化时对账

增值服务费用必须跟着商品总价变化。在两个时机进行对账：

- 结账页加载完成后一次
- 总价变动 `CheckoutAPI.store.onPricesChange` 回调里——平台在每轮重算价后触发它（覆盖应用/取消折扣码、礼品卡，地址变化引起的重算，以及你自己的加行删行）

对账逻辑：按最新商品总价重新选出应选档位，和订单里保险行的实际档位（`variantId`）比对；不一致就删掉旧行、换应选档位重新加入。

```javascript
// busy：变更进行中的互斥标记，防止重复发起
let busy = false;
// retries：单轮对账的修正次数，防止费用永远对不上时无限重试
let retries = 0;

// 刷新开关 UI：line 为 null 表示订单里没有保险行（开关置灰关）
function syncUi(api, line) {
  document.getElementById('protection-widget').style.display = 'block';
  document.getElementById('protection-toggle').checked = Boolean(line);
  document.getElementById('protection-fee').textContent =
    line ? line.linePrice : expectedTier(api).price.toFixed(2);
}

function hideWidget() {
  document.getElementById('protection-widget').style.display = 'none';
}

async function reconcile(api) {
  if (busy) return;
  // 订单里没有保险行：顾客没开启，仅刷新费用显示
  const line = api.summary.getProductList().find(isProtectionLine);
  if (!line) return syncUi(api, null);

  const tier = expectedTier(api);
  // 订单里的档位就是应选档位：对账通过，清零重试计数
  // （比 variantId 而不比价格，避免多币种下金额对不上）
  if (line.variantId === tier.variantId) {
    retries = 0;
    return syncUi(api, line);
  }
  // 连续修正 2 次仍不一致：移除保险行并隐藏组件，
  // 保证顾客不会按错误的费用付款（宁可这单不卖保险）
  if (retries >= 2) {
    await api.order.removeLineItems({ mutationSource: SOURCE, lineItemIds: [line.id] });
    return hideWidget();
  }
  // 修正：删旧行 → 按新费用选档位重新加入 → 再对一次账刷新 UI
  // （换档期间的价格事件被 busy 挡掉了，必须主动补一次对账，否则开关状态不会更新）
  retries += 1;
  busy = true;
  await api.order.removeLineItems({ mutationSource: SOURCE, lineItemIds: [line.id] });
  await addProtection(api);
  busy = false;
  return reconcile(api);
}

// 入口：等 CheckoutAPI 就绪后绑定开关事件、做首次对账、挂价格监听
mount(api => {
  document.getElementById('protection-toggle').addEventListener('change', async e => {
    if (busy) return;
    busy = true;
    e.target.disabled = true;  // 请求期间禁用开关，防止连点
    const res = e.target.checked ? await addProtection(api) : await removeProtection(api);
    e.target.disabled = false;
    busy = false;
    // 失败时把开关拨回原状
    if (res && res.state !== 'success') e.target.checked = !e.target.checked;
    reconcile(api);
  });
  reconcile(api);
  // 平台每轮重算价后触发，作为对账的统一入口；
  // 事件触发瞬间 getProductList 的折后价可能还没更新，600ms 后再复查一次
  api.store.onPricesChange(() => {
    reconcile(api);
    setTimeout(() => reconcile(api), 600);
  });
});
```

对账不会死循环：自己的修正会再触发一次 `onPricesChange`，但此时应选档位与订单里的档位已一致，函数直接退出。

:::note
1. `onPricesChange` 触发的瞬间，`getProductList()` 返回的折后价（`finalLinePrice`）可能尚未更新，只用即时对账会漏掉换档，所以代码里加了一次延迟复查；
2. 换档是「删旧行 + 加新行」两次串行变更，每次都触发平台整单重算，全程约 2\~4 秒——换档期间给开关加 loading 状态，让顾客知道正在处理。
:::

:::warning
限时促销等活动会直接修改增值服务的 variant 售价，发生在结账算价之前，`_shoplazza_exclude_calculation` 无法隔离——被打折的增值服务行会保持折后价。需要提醒商户不要把增值服务商品加入任何会改动其售价的促销活动。
:::

## 验证

在扩展项目中运行 `shoplazza app dev`，扩展推送到绑定店铺后，在店面加购商品并进入结账页：

1. 优惠券输入框下方出现开关，旁边显示按当前商品总价算出的费用；
2. 打开开关——订单摘要多出一行增值服务商品，总价增加对应金额；该行不带折扣标；
3. 关闭开关——该行消失，总价还原；
4. 应用一个折扣码——普通商品行出现折扣，增值服务行价格不变（隔离生效），若费率基于折后价，开关旁的费用随之更新；
5. 打开浏览器控制台确认无报错。

修改扩展源码后需重启 `shoplazza app dev` 才会重新推送。

显示效果：

![结账页效果](/img/checkout/modify-order-at-checkout.jpg)

## 下一步

- [扩展点](/docs/app/extensions/checkout/extension-points)——可以插入内容的全部插槽
- [CheckoutAPI 参考](/docs/app/extensions/checkout/checkout-api)——完整的 `CheckoutAPI` 能力
- [Checkout extension 场景示例](/docs/app/extensions/checkout/extension-recipes)——更多场景，含 API 调用与事件监听
