# 用函数按商品加收附加费

> 用 Shoplazza cart_transform 函数把商品元字段里设置的附加费加到购物车和结账页的价格上，商品标价保持不变。

## 背景

有些商品在售价之外还要加收一笔费用，比如刻字商品的定制费、易碎商品的包装费，每个商品的金额都不一样。Shoplazza 的商品价格存在变体上，没有函数时，商家只能把这笔费用算进变体价格里，于是带来三个问题：

- 商品页显示的是已经含费用的价格，买家看到的标价变高了，也分不清其中多少是附加费；
- 费用一调整，商家就要逐个商品手动改变体价格，费用和价格很容易对不上；
- 结账页只显示一个合计价格，买家看不出这里加了一笔附加费。

这三个问题的根源相同：买家付的就是变体价格，从加购到付款之间，没有任何环节能让应用在这个价格上按商品加一笔钱。因此本方案的核心目标是：商品标价保持不变，把附加费存在每个商品上，只在购物车和结账页把它加到商品行价格里，并在结账页的商品标题里标出这笔费用。

## 工作原理

1. **商家设置附加费**：应用在安装时创建一个商品元字段定义，本例用的是 `custom.markup`。元字段是挂在商品等资源上的自定义字段。商家在需要加收费用的商品上给这个字段填整数金额，不收费的商品留空。
2. **应用声明函数要读的字段**：函数的 `shoplazza.extension.toml` 在 `[[metafields_query]]` 里列出 `custom.markup`，`app deploy` 时这份声明随函数一起提交。
3. **安装时平台绑定函数**：商家安装应用时，平台把已发布的函数连同声明一起绑定到店铺。
4. **买家加购商品**：平台以购物车为输入运行函数，每个商品行的 `product.metafields` 里带着该商品的 `custom.markup` 值。
5. **函数返回调价结果**：有附加费的商品行，函数返回等于附加费的 `adjustment_fixed_price` 和新标题；没有附加费的商品行不出现在输出里。
6. **平台应用结果**：商品行单价变为原价加 `adjustment_fixed_price`。创建结账时函数会再运行一次，且在所有折扣之前运行，新标题显示在结账页。

整体流程如下：

```mermaid
flowchart TD
    A[商家给商品设置 custom.markup] --> B[app deploy 提交函数和 metafields_query]
    B --> C[商家安装应用：平台绑定函数]
    C --> D[买家加购商品]
    D --> E[平台运行函数，输入购物车和商品元字段]
    E --> F{商品行有 custom.markup？}
    F -- 有 --> G[返回 adjustment_fixed_price = markup 和新标题]
    F -- 没有 --> H[不输出该商品行]
    G --> I[单价 = 原价 + markup]
    H --> J[商品行保持原价]
    I --> K[结账页：函数在折扣前再次运行，标题标出附加费]
```

## 示例开发

下面一步步实现这个函数。标价 100、`custom.markup = 5` 的商品，在购物车和结账页显示为 105，结账页标题显示为 `Markup Tee (+5)`；没有这个元字段的商品价格不变。

## 前置条件

- 用 `shoplazza app init` 创建好的应用项目，见[构建你的第一个应用](/docs/app/developer-tools/shoplazza-cli/tutorials/build-your-first-app)。
- [Shoplazza CLI](/docs/app/developer-tools/shoplazza-cli) 2.2.5 或更高版本。更早的版本不会提交 `[[metafields_query]]` 声明，函数拿不到任何元字段。
- `shoplazza.app.toml` 的 `scopes` 里包含 `write_cart_transform`，默认应用模板已经带上。
- 一个用来安装应用的测试店铺。

函数的编译、发布、绑定等通用机制，见 [Function 扩展开发指南](/docs/app/extensions/function)。

## 第 1 步：应用安装时创建元字段定义

元字段定义声明了某类资源上一个字段的命名空间、键和类型。商品元字段定义建好之后，店铺后台每个商品编辑页的底部都会出现这个字段，商家可以直接填写。

让应用在安装时通过接口创建定义，不要让商家手动去建。函数只读取它声明过的命名空间和键，商家手动建时只要填错一个，函数就找不到附加费，价格不变，也没有任何报错。由代码创建定义，可以保证定义、声明和函数代码里用的是同一个名字。

新增一个商品元字段来存附加费，比如本篇一直用的 `custom.markup`。名字可以自己取，但第 3 步的声明和第 4 步的函数代码里，要用同一组命名空间和键。

`POST /openapi/2026-01/metafield_definition/product`

请求体：

```json5
{
  "namespace": "custom",
  "key": "markup",
  "name": "Markup",
  "type": "integer",  // 附加费是整数金额
  "description": "Amount added to the product price in the cart and at checkout"
}
```

响应（节选，新建的定义在 `data.metafield_definition`）：

```json
{
  "code": "Success",
  "data": {
    "metafield_definition": {
      "id": "705345636089727677",
      "namespace": "custom",
      "key": "markup",
      "name": "Markup",
      "owner_resource": "product",
      "type": "integer"
    }
  }
}
```

定义属于店铺，每个店铺只需创建一次。完整字段见[创建元字段定义](/api/metafield-definition-create)。

之后由商家设置附加费：在店铺后台打开需要加收费用的商品的编辑页，在底部「元字段」里的 **Markup** 字段填入金额；不收费的商品留空。如果你的应用有自己的设置页，想让商家在那里填写，也可以由应用调用[创建元字段](/api/metafield-create)接口写入。

## 第 2 步：创建函数扩展

在应用项目根目录下，创建一个名为 `markup-price` 的函数扩展：

```shell
shoplazza app extension create --type function --name markup-price
```

生成的目录如下：

```
extensions/
└── markup-price/
    ├── .gitignore
    ├── package.json
    ├── shoplazza.extension.toml   # 扩展配置
    └── src/
        └── index.js               # 函数入口文件
```

## 第 3 步：声明函数要读取的元字段

函数只能拿到它声明过的元字段。在 `extensions/markup-price/shoplazza.extension.toml` 里加一个 `[[metafields_query]]` 块：

```toml
id = ""
name = "markup-price"
type = "function"

# 把商品元字段 custom.markup 加载到每个商品行的 product.metafields 里
[[metafields_query]]
resource = "product"
namespace = "custom"
key = "markup"
```

- `resource`：`product` 或 `shop`。商品元字段出现在每个商品行的 `product.metafields` 里；
- `namespace` 和 `key`：要和第 1 步的定义一致；
- 一个函数最多声明 5 条。`app deploy` 会校验声明，不合法时发布失败。

:::note
不写这个块，函数照常运行，但 `product.metafields` 是空数组 `[]`。函数找不到附加费，所有价格都不变，也不会有任何报错。价格没变时，先检查这个块。
:::

## 第 4 步：编写函数逻辑

函数从 `stdin` 读入购物车，把结果写到 `stdout`。脚手架生成的 `src/index.js` 里已经有负责读写的 `readInput()` 和 `writeOutput()`，保留它们，把 `run()` 换成下面的逻辑：

```javascript
// extensions/markup-price/src/index.js

const input = readInput();
const result = run(input);
writeOutput(result);

function run(input) {
  // 收集需要调价的商品行，每个有附加费的商品行一条
  const update = [];

  input.cart.line_items.forEach((lineItem) => {
    // [[metafields_query]] 里声明过的元字段；没有声明时是空数组
    const metafields = lineItem.product.metafields || [];

    // 找到这个商品的附加费：命名空间为 custom、键为 markup 的元字段
    const markupField = metafields.find(
      (metafield) => metafield.namespace === "custom" && metafield.key === "markup"
    );

    // 这个商品没有附加费：不输出该商品行，它保持原价
    if (!markupField) {
      return;
    }

    // parseFloat 数字和数字字符串都能处理
    const markup = parseFloat(markupField.value);

    // adjustment_fixed_price 必须在 0 到 999999999 之间。
    // 只要有一行不合法，平台会拒绝整个输出，所以在这里跳过不合法的值。
    if (!Number.isFinite(markup) || markup < 0 || markup > 999999999) {
      return;
    }

    update.push({
      // 要修改的商品行：用输入里的商品行 id
      id: lineItem.id,
      price: {
        // 加在原单价上的金额，保留 2 位小数的字符串。
        // 最终单价 = 原价 + adjustment_fixed_price。
        adjustment_fixed_price: markup.toFixed(2),
      },
      // 新的商品行标题，显示在结账页；购物车页仍显示原标题
      title: `${lineItem.product.product_title} (+${markup})`,
    });
  });

  // update 是唯一支持的操作
  return { operation: { update } };
}

// readInput() 和 writeOutput() 保持脚手架原样
```

购物车里有两个商品，一个 `custom.markup = 5`、一个没有这个元字段时，函数收到的输入（节选）：

```jsonc
{
  "cart": {
    "line_items": [
      {
        "id": "line-a",
        "quantity": 1,
        "product": {
          "price": "100.00",
          "product_title": "Markup Tee",
          "metafields": [{ "namespace": "custom", "key": "markup", "value": 5 }]
        }
      },
      {
        "id": "line-b",
        "quantity": 1,
        "product": {
          "price": "50.00",
          "product_title": "Plain Cap",
          "metafields": []
        }
      }
    ]
  }
}
```

返回：

```json
{
  "operation": {
    "update": [
      {
        "id": "line-a",
        "price": { "adjustment_fixed_price": "5.00" },
        "title": "Markup Tee (+5)"
      }
    ]
  }
}
```

`line-b` 不在输出里，所以保持 50。完整的输入输出约定见[编写函数逻辑](/docs/app/extensions/function#第-2-步--编写函数逻辑)。

:::warning
`adjustment_fixed_price` 是**加在**原价上的金额，不是替换原价。标价 100 的商品返回 `"105.00"`，商品行会变成 205。只返回附加费本身。
:::

## 第 5 步：发布应用

在应用项目根目录下发布应用。这一步会编译函数，把它和声明一起上传，并生成一个新的应用版本：

```shell
shoplazza app deploy
```

输出里函数那一项带有 `input_query`，也就是第 3 步的声明：

```json
{
  "extension_id": "4330659779216150544",
  "name": "markup-price",
  "type": "function",
  "version": "1.0.3",
  "input_query": "{\"metafields_query\":[{\"resource\":\"product\",\"namespace\":\"custom\",\"key\":\"markup\"}]}"
}
```

函数那一项没有 `input_query`，说明声明没有提交上去，检查 CLI 版本和 `[[metafields_query]]` 块。

## 第 6 步：在测试店铺安装应用

平台在应用安装时把函数绑定到店铺。在 Partner 后台打开你的应用，在「测试App」里选择测试店铺，点「安装App」。

:::warning
在这次发布之前就装了应用的店铺，会继续运行它绑定时的函数版本，重新发布不会让它切换过去。要测试新的函数版本或修改后的声明，在测试店铺上卸载并重新安装应用。
:::

## 验证

在测试店铺上：

1. 把设置了 `custom.markup = 5`、标价 100 的商品加入购物车。商品行显示 105，标题不变；
2. 再加购一个没有这个元字段的商品，价格保持标价；
3. 进入结账页，第一个商品行仍是 105，标题显示为 `Markup Tee (+5)`；
4. 打开商品页，仍显示标价 100。

:::note
给购物车里已有的商品行增加数量，价格仍是加购时算出的价格。改了附加费或函数之后，先删掉这一行再重新加购，才能看到新价格。
:::

结账页效果如下：

![结账页商品行标题为 Markup Tee (+5)、价格为 105](/img/function/add-surcharge-by-metafield.png)

## 下一步

- [Function 扩展开发指南](/docs/app/extensions/function)：编译、发布、绑定，以及完整的输入输出约定
- [创建元字段定义](/api/metafield-definition-create)：元字段定义接口的全部字段
- [按尺寸计价销售商品](/docs/app/use-cases/product-merchandising/sell-by-measurement)：按买家填写的尺寸计价
