前言

商家建立一個下週開始的 Shopify Collection 折扣,看起來只需要保存 Collection ID、開始時間與折扣率。第一版實作卻連續踩到兩個不同問題:

  1. 未來規則在儲存當下被視為「尚未啟用」,因此沒有進入 Function 設定。
  2. Collection 於儲存時被展開成當下的 Product ID 清單,活動開始前新增的商品不會得到折扣。

第二個問題還會讓 function-owner metafield 隨 Collection 成長,最後觸發上一篇談到的 Discount Function 設定大小限制

這篇聚焦在資料的時間語意:排程規則要保存的是「未來如何判斷」,不是「今天判斷後得到的商品快照」。

原本的做法與問題症狀

最早的 Function 只會比對 Product ID,所以後台在商家選擇 Collection 後,會呼叫 Admin API 讀取其中的商品,將它們展開成:

{
  "collectionGids": ["gid://shopify/Collection/example"],
  "productGids": [
    "gid://shopify/Product/example-a",
    "gid://shopify/Product/example-b"
  ]
}

這讓 Function 不必查 Collection membership,當下測試也會成功。

但加入排程後,runtime config builder 為了「只同步有效規則」,先使用 server 現在時間過濾 startsAtendsAt。下週才開始的規則因此被排除,Automatic App Discount 雖然有未來 startsAt,它的 metafield 裡卻沒有可執行 rule。

第一個修正是保留 future rule,由 Shopify discount node 的 schedule 決定何時啟用。這解決了空規則,仍留下 Collection 快照問題:

  • 活動建立時 Collection 有 A、B。
  • 活動開始前商家加入 C。
  • Function 設定仍只有 A、B。
  • C 在 Collection 頁看得到,Checkout 卻沒有折扣。

反過來,活動開始前移出 Collection 的商品也可能繼續存在於舊 Product ID 清單。

問題是怎麼推敲出來的?

這次需要分清楚三個時間點:

建立活動
    ↓
活動開始
    ↓
顧客進入 Cart/Checkout

Collection 是可變的業務規則。手動 Collection 可以增減商品,自動 Collection 也可能因 tag、類型或價格條件改變 membership。

如果在「建立活動」時展開商品,得到的只是那一刻的 snapshot。排程真正想表達的卻是:「活動執行期間,凡是當下屬於這個 Collection 的商品都符合條件。」

因此 membership 應該盡量靠近交易時計算。Shopify Discount Function schema 提供 inAnyCollection(ids:),可以在 Function input query 執行時回傳 cart line 的商品是否屬於指定 Collection。

input query variables 又能從 function owner 的 JSON metafield取得 Collection ID,讓不同 discount nodes 使用同一版 Function code,不必每次選取 Collection 都重新部署 extension。

最後採用的架構

最終資料流改成:

商家選擇 Collection
       ↓
保存 Collection GID 與未來排程
       ↓
建立有 startsAt/endsAt 的 Automatic App Discount
       ↓
Function runtime 將 Collection GID 帶入 input query
       ↓
Shopify 計算 cart line 當下的 Collection membership
       ↓
Function 依布林結果產生 product discount candidate

Function 設定仍保留 future rule,不再按 server 的「現在」刪除它。是否到達活動時間,交由 Automatic App Discount 的 startsAtendsAt 控制。

Collection rule 也不再保存展開後的 Product ID 或 product handle,只保留 Collection GID。直接由商家挑選的個別商品才繼續使用 productGids

關鍵實作

Function input query 使用可變的 Collection ID list:

query Input($collectionGids: [ID!] = []) {
  cart {
    lines {
      merchandise {
        ... on ProductVariant {
          product {
            inSelectedCollections: inAnyCollection(ids: $collectionGids)
          }
        }
      }
    }
  }
}

extension configuration 指定 $collectionGids 要從 function-owner JSON metafield 取得。Shopify 文件要求這類 variable 使用 JSON metafield,且 list 不能超過 100 個元素,因此 Web App 在建立或更新 discount 前先限制 Collection 數量。

Function runtime 的比對則保留兩種入口:

const matches =
  rule.productGids.includes(productId) ||
  (
    rule.collectionGids.length > 0 &&
    inSelectedCollections === true
  );

這個布林值代表是否命中指定 Collection 之一。Function 不需要知道完整商品清單,也不需要把 Collection 資料同步進自己的資料庫。

實際驗證

這次測試刻意涵蓋兩次修正的交界:

  • 未來開始的 rule 仍存在於 Automatic App Discount metafield。
  • discount node 的 startsAt 保留原排程。
  • Collection rule 的 collectionGids 不會在 normalize 過程中遺失。
  • 精簡 runtime config 中,Collection rule 的 productGids 可以是空陣列。
  • Collection 商品與 handle 不再整批寫進 Function 設定。
  • fixture 將 inAnyCollection 結果設為 true 時,Function 能產生折扣。
  • false 時不會因為只選了 Collection 就套用折扣。
  • 超過 input variable list 限制時,Web App 在寫入前停止。
  • 精簡後的 future Collection config 仍低於 metafield input byte limit。

比起只測「建立活動成功」,這組測試同時確認 schedule contract、variable contract 與 Function runtime behavior。

仍然存在的限制

inAnyCollection 的 input query argument 仍受 list 上限與 query cost 約束。這套設計適合「命中任一指定 Collection 就使用同一條規則」,不適合一次查詢數百個 Collection。

布林 alias 也不會告訴 Function 究竟命中哪一個 Collection。如果每個 Collection 有不同折扣率,可以拆成不同 Automatic App Discounts、設計多個 membership 欄位,或重新思考 Promotion 的資料模型。

另外,runtime membership 代表 Collection 修改可能立即影響之後的 Cart/Checkout。這通常符合動態 Collection 的直覺,但若商家需要「活動建立當下就凍結名單」,那是另一種產品需求,應明確建立 snapshot,而不是混用兩種語意。

最後,Cart 可能保留舊的計算狀態。測試設定變更後,應透過調整數量、重新加入商品或進入 Checkout 觸發重新計算,不能只看舊 cart drawer 就判定新 Function 失效。

可以延伸到哪些情境?

這個案例可以整理成一個資料建模判斷:

  • 需求是「當時有哪些商品」:建立 snapshot。
  • 需求是「執行時符合哪些條件」:保存規則,在 runtime 判斷。

排程、會員分群、庫存條件、地點與自動 Collection 都可能因時間改變。越早把動態規則展開成靜態 ID,越需要額外同步與失效處理。

下一篇會換到完全不同的特價機制:Shopify 批量特價不能只改 Variant Price:用 Snapshot 與 Restore 安全回復

參考資料