# 按尺寸计价销售商品

> 用 Ajax API 的行项目属性按买家填写的尺寸给 Shoplazza 商品计价（按米卖布、按卷卖墙纸），并从订单读回。

让顾客在商品页填写尺寸，价格随之变化——按平方米卖的定制壁画、按长度卖的壁纸卷、按米卖的布料。第 1 到 6 步用店铺前台 JavaScript 和 Ajax API 实现，整条购买链路都在主题内完成。只有第 7 步——从订单读回尺寸——会调用 Open API。

本篇是行项目属性的一个完整应用示例。通用规则——属性怎么写入、顾客看到什么、怎么读回——先看 [给购物车和订单添加自定义属性](/docs/app/use-cases/product-merchandising/add-custom-properties)。

## 背景

Shoplazza 的商品价格挂在变体上，而变体价格是固定的。按尺寸售卖的商家因此被卡住：

- 商品页能显示算好的价格（「6 m² × $19.90 = $119.40」），但那只是一段文字，进不了购物车。
- 无论顾客填了多大尺寸，加购时都只按变体价收一次钱。
- 顾客填的尺寸也丢了，生产这件商品的工厂拿不到任何依据。

三件事的根源是同一个：整条购买链路里没有任何入口接收前台算出来的价格。

但这条链路里有一样东西是你能控制的——**数量**。如果变体价格代表的不是「一件成品」而是「一个很小的计量单位」，数量就能承载尺寸，平台自己的算术会把总价算对。尺寸本身则作为行项目属性一同带走。

因此本篇的核心目标是：**让购物车金额与顾客填写的尺寸一致，并把这个尺寸一路带到订单上**——全程不改变体价格。

## 工作原理

整套做法分五个阶段：

1. **把价格建模成计量单位。** 把变体价格设为一个**计价单位**的价格——例如每 `0.1 m²` 卖 `$1.99`，也就是每平方米 `$19.90`。
2. **收集尺寸。** 商品页上的 App Block 渲染尺寸输入框，并实时显示总价。
3. **换算成计价份数。** `份数 = ceil(面积 / 计价单位)`。向上取整，保证不会少收钱。
4. **拦截购买按钮。** 在主题原生的「加入购物车」和「立即购买」按钮上挂捕获阶段的 click 处理函数，让原生逻辑不会带着 `quantity: 1` 跑起来。
5. **提交。** 加购走 `POST /{locale}/api/cart`，立即购买走 `POST /{locale}/api/checkout/order`。两者都带上 `quantity`（计价份数）和 `properties`（尺寸）。

尺寸作为行项目属性随单流转，最终落到订单的 `custom_properties` 上，你的履约系统从这里读取。

```mermaid
flowchart TD
    A[顾客填写宽和高] --> B[Block 计算面积]
    B --> C["份数 = ceil(面积 / 计价单位)"]
    C --> D{点了哪个按钮？}
    D -- 加入购物车 --> E["POST /api/cart<br/>quantity + properties"]
    D -- 立即购买 --> F["POST /api/checkout/order<br/>line_items[0] 带 quantity + properties"]
    E --> G[购物车显示份数与算好的总价]
    F --> H[跳转到 checkout_url]
    G --> I["订单行项目：quantity = 份数，<br/>custom_properties = 尺寸"]
    H --> I
```

## 前置条件

- 一个 **App Block** 类型的主题应用扩展——见 [开发主题扩展](/docs/app/extensions/online-store/add-theme-extension)。App Block 渲染在页面内部，商家在主题编辑器里把它放进购买区即可，你不需要去猜主题的 DOM 结构。
- 一个变体价格等于一个计价单位、并且**已关闭库存跟踪**的商品。这一条不是可选项，原因见第 1 步的警告。
- 了解 [购物车 Ajax API](/docs/theme/references/ajax-api/cart) 与 [结账 Ajax API](/docs/theme/references/ajax-api/checkout)。

## 第 1 步：设置商品

选一个计价单位，小到让取整误差可以接受，然后把变体价格设成这个单位的价格。

一幅每平方米 `$19.90` 的壁画，取 `0.1 m²` 作为计价单位，变体价格就是 `$1.99`。一幅 2 m × 3 m 的壁画是 6 m²，也就是 60 个计价份数，顾客支付 `$1.99 × 60 = $119.40`。

单位越小，取整误差越小，数量数字也越大。`0.01 m²` 的精度高 10 倍，但同一幅壁画在顾客购物车里会显示成 `600`。

:::warning
**这个商品必须关闭库存跟踪。** 打开库存跟踪后，库存数就不再表示「还剩几件」，而是「还剩几个计价份数」——卖 6 m² 需要 60 个库存。更麻烦的是两条购买路径的失败方式并不一样：

- `POST /api/cart` 直接拒绝请求，返回 HTTP 406 和 `You can only add 10 pieces to the cart.`。
- `POST /api/checkout/order` 返回 HTTP 200，但会**静默把数量截断到剩余库存**。顾客看到的是 6 m²、$119.40，实际建单是 $19.90，而 `custom_properties` 里仍然写着 `Area: 6.00 m²`。全程没有任何报错，工厂照着属性做出一幅 6 m² 的画，收的却是 1 m² 的钱。

如果商家确实需要对这个商品做库存管控，只能在平台的库存字段之外另想办法。
:::

## 第 2 步：渲染尺寸输入框

App Block 承载输入框和实时总价，并通过一个全局变量把商品上下文传给你的脚本。

```html title="blocks/area-pricing.liquid"
{% use "area-pricing.css" %}
{% use "area-pricing.js" %}

<div class="ap-root" data-ap-root>
  <label>宽度（cm） <input type="number" min="1" step="1" value="200" data-ap-width></label>
  <label>高度（cm） <input type="number" min="1" step="1" value="300" data-ap-height></label>

  <div class="ap-summary">
    <span>面积：<b data-ap-area>—</b></span>
    <span>计价份数：<b data-ap-units>—</b></span>
    <span>总价：<b data-ap-total>—</b></span>
  </div>
  <p class="ap-note" data-ap-note></p>
</div>

<script>
  window.__areaPricing = {
    productId: {{ product.id | json }},
    // product.variants.first 在 Shoplazza 上返回 null，要用数组下标取
    variantId: {{ product.variants[0].id | json }},
    unitPrice: {{ product.variants[0].price | json }},   // 字符串，例如 "1.99"
    unitArea: {{ block.settings.unit_area | default: 0.1 | json }}
  };
</script>

{% schema %}
{
  "name": { "en-US": "Area pricing", "zh-CN": "按面积计价" },
  "settings": [
    {
      "type": "text",
      "id": "unit_area",
      "label": { "en-US": "Billing unit in m²", "zh-CN": "计价单位（m²）" },
      "default": "0.1"
    }
  ]
}
{% endschema %}
```

:::note
`product.variants.first` 在这里返回 `null`，要改用 `product.variants[0]`（或者用带 `limit: 1` 的 `{% for %}` 循环）。`product.variants[0].price` 取到的是字符串（如 `"1.99"`），参与运算前要先转成数字。
:::

本篇假设商品是单变体的，这也是定制类商品通常的形态：尺寸是输入项，不是变体。如果你的商品确实有多个变体，当前选中的变体要从主题自己的变体控件读取，而不是取 `variants[0]`。

## 第 3 步：把尺寸换算成计价份数

**向上取整。** 顾客填 205 cm × 305 cm，需要 6.2525 m²，也就是 62.525 个计价份数——而数量必须是整数。向上取整按 63 份收费，商家不会少收；向下取整或四舍五入都会以低于成本的价格卖出材料。

```javascript title="assets/area-pricing.js"
var cfg = window.__areaPricing;
var UNIT_AREA = parseFloat(cfg.unitArea) || 0.1;   // 一个变体价格覆盖多少平方米
var UNIT_PRICE = parseFloat(cfg.unitPrice) || 0;

function calc() {
  var w = parseFloat(document.querySelector('[data-ap-width]').value);
  var h = parseFloat(document.querySelector('[data-ap-height]').value);
  if (!(w > 0) || !(h > 0)) return null;

  var area = (w * h) / 10000;                  // cm² 换算成 m²
  var units = Math.ceil(area / UNIT_AREA);     // 向上取整：绝不少收
  return { w: w, h: h, area: area, units: units, total: units * UNIT_PRICE };
}
```

顾客边输入边显示结果，让他看到的价格就是最终要付的价格：

```javascript
function render() {
  var r = calc();
  var root = document.querySelector('[data-ap-root]');
  root.querySelector('[data-ap-area]').textContent = r ? r.area.toFixed(2) + ' m²' : '—';
  root.querySelector('[data-ap-units]').textContent = r ? String(r.units) : '—';
  root.querySelector('[data-ap-total]').textContent = r ? r.total.toFixed(2) : '—';
}

document.querySelector('[data-ap-width]').addEventListener('input', render);
document.querySelector('[data-ap-height]').addEventListener('input', render);
render();
```

填 200 × 300 时，这里显示`面积：6.00 m²`、`计价份数：60`、`总价：119.40`。

## 第 4 步：拦截购买按钮

主题原生的「加入购物车」和「立即购买」按钮会提交 `quantity: 1`。给它们各挂一个捕获阶段的 click 处理函数，让你的代码先跑，原生处理函数不会被触发。

这一步的风险不在于「挂不上」，而在于**挂错了按钮**。劫持一个认错的按钮等于把它改写成加购动作；如果那个按钮恰好是购物车抽屉的「结算」，顾客就彻底下不了单了。所以作用域要收紧：只在**当前这个商品**的商品表单里找。

```javascript
// 限定在本商品的商品表单内。不要只用 [product-id="<id>"] 选择——
// 页面上其他模块（比如「最近浏览」）带的是同一个 product-id，
// 它们的按钮会被一起捞进来。
function scopes() {
  var pid = cfg.productId;
  return [].slice.call(document.querySelectorAll(
    'spz-product-form[product-id="' + pid + '"], form[product-id="' + pid + '"]'
  ));
}

// 判断按钮类型。带 data-track-source 的主题上它是最可靠的信号；
// 类名和文案是给没有这个属性的主题兜底用的。
function buyActionOf(el) {
  var src = (el.getAttribute('data-track-source') || '').toLowerCase();
  if (src === 'buy_now' || src === 'add_to_cart') return src;

  var cls = String(el.className || '');
  if (/buy[-_]?now/i.test(cls)) return 'buy_now';
  if (/add[-_]?to[-_]?cart/i.test(cls)) return 'add_to_cart';
  return null;
}

function bind() {
  scopes().forEach(function (scope) {
    var candidates = [].slice.call(scope.querySelectorAll('button, spz-atc, [type="submit"]'));
    // 有些主题上，作用域根节点自己就是目标按钮
    if (scope.matches('button, spz-atc, [type="submit"]')) candidates.unshift(scope);

    candidates.forEach(function (el) {
      var action = buyActionOf(el);
      if (!action || el.__apBound) return;
      el.__apBound = true;
      el.__apAction = action;
      el.addEventListener('click', onBuyClick, true);   // true = 捕获阶段
    });
  });
}

bind();
// 主题在切换变体或改数量时会重绘购买区，按钮节点会被换掉，
// 发生变化时重新绑定。
new MutationObserver(bind).observe(document.body, { childList: true, subtree: true });
```

:::warning
劫持这条路径上的作用域判断必须严格。「带有主题的主按钮类名」这种通用信号会命中登录按钮、空购物车按钮，以及购物车抽屉的「结算」——它们都不在任何商品表单里。多藏一个按钮还能补救，劫持错一个补救不了。
:::

## 第 5 步：加入购物车

把计价份数作为 `quantity` 提交，尺寸作为 `properties` 提交。

不带下划线前缀的键对顾客可见，带前缀的隐藏但 API 仍会返回（见 [下划线前缀的作用](/docs/app/use-cases/product-merchandising/add-custom-properties#hide-from-shopper)）。下面用 `Width` / `Height` / `Area` 作为可见键，你可以按店铺面向的语言自行调整。

```javascript
function root_() {
  // 多语言店铺的路径带 locale 前缀，URL 一律从这里拼
  return window.SHOPLAZZA.routes.root;
}

function buildProperties(r) {
  return {
    'Width':  r.w + ' cm',                     // 顾客可见
    'Height': r.h + ' cm',
    'Area':   r.area.toFixed(2) + ' m²',
    '_billing_unit_area': String(UNIT_AREA),   // 隐藏：给履约系统读
    '_billing_units': String(r.units)
  };
}

function addToCart(r) {
  return fetch(root_() + '/api/cart', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      product_id: cfg.productId,
      variant_id: cfg.variantId,
      quantity: r.units,              // 计价份数，不是件数
      properties: buildProperties(r),
      refer_info: { source: 'add_to_cart' }
    })
  }).then(function (res) {
    if (!res.ok) throw new Error('cart_add_failed ' + res.status);
    return res.json();
  });
}
```

加购后读回购物车，可以看到平台实际存下来的行项目：

```json
{
  "cart": {
    "item_count": 60,
    "total_price": "119.40",
    "line_items": [
      {
        "variant_id": "36e7ca49-343f-4ac2-a2d8-8ef2d8f7a105",
        "quantity": "60",
        "price": "1.99",
        "properties": "{\"Width\":\"200 cm\",\"Height\":\"300 cm\",\"Area\":\"6.00 m²\",\"_billing_unit_area\":\"0.1\",\"_billing_units\":\"60\"}"
      }
    ]
  }
}
```

:::note
`quantity` 返回的是字符串，`properties` 返回的是一段需要你自己解析的 **JSON 字符串**。而在订单侧，同一份数据出现在 `custom_properties` 下，是**对象**——见第 7 步。两处形态不同，各自按各自的形态处理。
:::

加购之后不需要调用任何主题接口去刷新购物车。主题监听的是 `POST /api/cart` 这个请求本身，它会自己打开购物车抽屉。

## 第 6 步：立即购买

立即购买不经过购物车。它用 `POST /{locale}/api/checkout/order` 直接创建一个结账会话，然后把顾客跳转到返回的地址。

`line_items[]` 的每一行都接受 `properties`，与购物车接口完全一致。不要传价格——服务端按变体自己定价，这正是整套做法成立的前提。

```javascript
function buyNow(r) {
  return fetch(root_() + '/api/checkout/order', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      refer_info: { source: 'buy_now' },
      line_items: [{
        product_id: cfg.productId,
        variant_id: cfg.variantId,
        quantity: r.units,
        note: '',
        properties: buildProperties(r)
      }]
    })
  }).then(function (res) {
    if (!res.ok) throw new Error('checkout_create_failed ' + res.status);
    return res.json();
  }).then(function (body) {
    var url = body.data && body.data.checkout_url;
    if (!url) throw new Error('checkout_url_missing');
    location.href = url;          // 直达结账页，不动购物车
  });
}
```

一幅 6 m² 壁画的响应（节选）：

```json
{
  "state": "success",
  "data": {
    "order_token": "2446407211694842140211",
    "checkout_url": "/checkout/2446407211694842140211",
    "prices": {
      "total_price": "119.40",
      "subtotal_price": "119.40",
      "currency_code": "USD"
    }
  }
}
```

第 4 步绑定的点击处理函数按按钮类型分发到这两个提交函数：

```javascript
var busy = false;

function onBuyClick(e) {
  e.preventDefault();
  e.stopPropagation();          // 不能让主题自己的处理函数再跑一遍
  if (busy) return;

  var r = calc();
  if (!r) { note('请填写宽度和高度。'); return; }

  busy = true;
  var request = e.currentTarget.__apAction === 'buy_now' ? buyNow(r) : addToCart(r);
  request.then(function () {
    busy = false;
    note('已加入：' + r.units + ' 份，' + r.area.toFixed(2) + ' m²');
  }, function (err) {
    busy = false;
    note('这个尺寸没能加入购物车，请重试。');
    console.warn('[area-pricing]', err);
  });
}
```

:::note
这样创建结账会话不会动购物车，也符合顾客对「立即购买」的预期。如果改成走购物车来实现立即购买，有两个副作用值得避开：主题看到任何一次 `POST /api/cart` 都会弹出购物车抽屉；顾客在结账页放弃支付时，这件商品还会留在购物车里。
:::

## 第 7 步：从订单读取尺寸

这一步跑在你的服务端，不在主题里。它需要一个已完成 OAuth 授权与 token 存储的应用——见 [开发独立应用](/docs/app/getting-started/create-public-app/develop-app)——以及 `order` 访问权限范围。

在订单上，你提交的属性出现在每个行项目的 `custom_properties` 下，是已经解析好的对象：

`GET /openapi/2026-01/orders/{id}`

```json
{
  "order": {
    "number": "JMW07973",
    "total_price": "125.37",
    "line_items": [
      {
        "quantity": 63,
        "price": "1.99",
        "custom_properties": {
          "Width": "205 cm",
          "Height": "305 cm",
          "Area": "6.25 m²",
          "_billing_unit_area": "0.1",
          "_billing_units": "63"
        }
      }
    ]
  }
}
```

带下划线前缀的键虽然任何界面上都不显示，在这里是取得到的。同一个对象也会随 `orders/create` webhook 推送过来，履约类集成无需轮询即可拿到尺寸——见 [监听订单事件](/docs/app/use-cases/orders-and-fulfillment/listen-for-order-events)。

:::warning
写入时这个字段叫 `properties`，从订单读回时叫 `custom_properties`。在订单行项目上读 `properties` 会得到空值，而且不报错。
:::

## 验证

上线前在测试店逐条走一遍：

1. **块的位置**——在商品模板的主题编辑器里把这个块加到购买区，确认店铺前台的尺寸输入框出现在那里。
2. **实时总价**——填 200 和 300，块上应显示`面积：6.00 m²`、`计价份数：60`、`总价：119.40`。
3. **取整**——填 205 和 305，面积是 6.2525 m²，计价份数必须是 **63**，不是 62。
4. **加入购物车**——点主题原生的「加入购物车」。购物车里应有一条数量为 60、总价 `119.40` 的行项目，购物车页上 Width、Height、Area 各占一行，两个下划线键不出现。
5. **立即购买**——点主题原生的「立即购买」。应直接进入结账页，购物车保持点击之前的状态，结账页摘要显示计价份数和三条尺寸。
6. **订单**——完成下单后在后台打开订单。行项目显示 `1.99 USD x 63`，三条尺寸各占一行，两个下划线键不出现在页面上、但在 API 响应里取得到。
7. **没有误劫持**——在块生效的状态下打开购物车抽屉、点「结算」。它必须进入结账，而不是往购物车里再加一件。这一条用来验第 4 步的按钮作用域有没有放太宽。

## 采用之前要权衡的取舍

数量承载了尺寸，于是平台上一切按数量计算的东西，现在算的都是计价份数：

- **数量是露出来的。** 购物车角标、结账页摘要、后台订单页显示的都是 60 而不是 1，后台订单头部会写「63 件产品」，仓库和打包单据上也是同一个数字。
- **按件数的促销会提前触发。** 一个「满 3 件减 $5」的自动折扣，任何超过 0.3 m² 的订单都会命中——测试店实测：一幅 6 m² 的壁画触发了它，总价从 `$119.40` 降到 `$114.40`。按件数分档的促销活动要把这类商品排除掉。
- **按件数计费的运费方案会被放大。** 按订单金额计费的方案不受影响，因为金额本身是对的。按件数或按重量计费、且可能应用到这类商品的方案要逐个检查。
- **不能使用库存跟踪。** 见第 1 步的警告。

如果商家需要数量保持它原本的含义——因为促销、运费和仓库都依赖它——那这套做法就不适合，尺寸只能换一种方式定价。

## 下一步

- [购物车 Ajax API](/docs/theme/references/ajax-api/cart)
- [结账 Ajax API](/docs/theme/references/ajax-api/checkout)
- [开发主题扩展](/docs/app/extensions/online-store/add-theme-extension)
- [监听订单事件](/docs/app/use-cases/orders-and-fulfillment/listen-for-order-events)
