Order Builder (訂單計算引擎)
Overview
@rytass/order-builder 提供訂單計算與優惠政策應用引擎,支援複雜折扣邏輯、多階梯優惠和精確計算(Decimal.js)。
Quick Start
安裝
npm install @rytass/order-builder
基本使用
import { OrderBuilder, ValueDiscount, PercentageDiscount } from '@rytass/order-builder';
// 建立 Builder const builder = new OrderBuilder({ policies: [ new ValueDiscount(100), // 固定折 100 元 new PercentageDiscount(0.1), // 9 折 ], });
// 建立訂單 const order = builder.build({ items: [ { id: 'A', name: 'Product A', unitPrice: 500, quantity: 2 }, { id: 'B', name: 'Product B', unitPrice: 300, quantity: 1 }, ], });
console.log(order.itemValue); // 1300 (商品總額) console.log(order.discountValue); // 折扣金額 console.log(order.price); // 最終價格
Core Concepts
Builder Pattern
const builder = new OrderBuilder() .addPolicy(new ValueDiscount(100)) .addPolicy(new PercentageDiscount(0.1));
// build() 後政策鎖定 const order = builder.build({ items: [...] });
Order Properties
order.price // 最終價格 order.discountValue // 總折扣額 order.itemValue // 商品總額 order.itemQuantity // 商品總數量 order.discounts // 折扣說明陣列 order.itemRecords // 品項記錄 order.logisticsRecord // 運費記錄(若有設定運費) order.parent // 父訂單(若為子訂單)
Discount Policies
DiscountOptions (折扣選項)
所有折扣類別都支援以下選項:
interface DiscountOptions { id?: string; // 折扣識別碼 onlyMatched?: boolean; // 只計算符合條件的品項(預設: false) excludedInCalculation?: boolean; // 此折扣不計入折扣總額(預設: false) }
注意: conditions 是作為獨立的構造函數參數傳入,而非在 options 物件中。 自訂屬性(如 name )可透過泛型類型參數 DiscountOptions<T> 傳入。
ValueDiscount (固定額折扣)
import { ValueDiscount } from '@rytass/order-builder';
// 固定折 100 元 const discount = new ValueDiscount(100, { id: 'FLAT_100', name: '滿額折 100', });
// 進階選項 const advancedDiscount = new ValueDiscount(50, { id: 'SPECIAL_50', onlyMatched: true, // 只計算符合條件品項的金額 excludedInCalculation: true, // 此折扣不計入 discountValue });
PercentageDiscount (百分比折扣)
import { PercentageDiscount } from '@rytass/order-builder';
// 9 折 (折 10%) const discount = new PercentageDiscount(0.1, { id: 'DISCOUNT_10', name: '全館 9 折', });
StepValueDiscount (累積固定額折扣)
import { StepValueDiscount } from '@rytass/order-builder';
// 每滿 1000 折 100 (累積計算) // 例如:買 2500 元 → 折 200 (2 倍) const discount = new StepValueDiscount(1000, 100, { id: 'STEP_DISCOUNT', name: '每滿千折百', });
// 計算公式: discount = value * floor(itemValue / step) // 2500 元時: 100 * floor(2500 / 1000) = 100 * 2 = 200
// 可選參數 const discountWithLimit = new StepValueDiscount(500, 50, { id: 'LIMITED_STEP', stepLimit: 3, // 最多累積 3 次 (最多折 150) stepUnit: 'price', // 'price' (金額) 或 'quantity' (數量) onlyMatched: true, // 只計算符合條件的品項 });
注意: 這是「累積折扣」模式,每達到 step 金額就累積一次折扣,不是「階梯式」選擇最高折扣。
StepPercentageDiscount (累積百分比折扣)
import { StepPercentageDiscount } from '@rytass/order-builder';
// 每買 3 件累積一次 95 折 (複利計算) // 例如:買 6 件 → 0.95^2 = 90.25 折 const discount = new StepPercentageDiscount(3, 0.95, { id: 'QUANTITY_DISCOUNT', name: '每 3 件折 5%', stepUnit: 'quantity', // 以數量計算 step });
// 計算公式: discountRate = value ^ floor(multiplier / step) // discount = itemValue * (1 - discountRate)
// 以金額累積的範例 const priceBasedDiscount = new StepPercentageDiscount(1000, 0.95, { id: 'PRICE_STEP', name: '每滿千折 5%', stepUnit: 'price', // 預設,以金額計算 step stepLimit: 5, // 最多累積 5 次 (最多 0.95^5 ≈ 77.4 折) });
注意: 這是「累積複利折扣」模式,每達到 step 就額外乘以 value 折扣率。
ItemGiveawayDiscount (贈品折扣)
import { ItemGiveawayDiscount, ItemIncluded } from '@rytass/order-builder';
// 送 1 件 (從符合條件的品項中選最低價) const discount = new ItemGiveawayDiscount(1, new ItemIncluded({ items: ['A'] }), { id: 'BUY_A_GET_FREE', });
// 多種建構方式 // 1. value + conditions 陣列 + options new ItemGiveawayDiscount(1, [new ItemIncluded({ items: ['A'] })], { id: 'GIVEAWAY_1' });
// 2. value + 單一 condition + options new ItemGiveawayDiscount(1, new ItemIncluded({ items: ['A'] }), { id: 'GIVEAWAY_2' });
// 3. value + options (無條件) new ItemGiveawayDiscount(1, { id: 'FREE_ONE', strategy: 'HIGH_PRICE_FIRST' });
// 4. 只有 value (送最低價 1 件) new ItemGiveawayDiscount(1);
// 指定贈品選擇策略 const discountWithStrategy = new ItemGiveawayDiscount(2, new ItemIncluded({ items: ['B', 'C'] }), { strategy: 'LOW_PRICE_FIRST', // 先送低價品(預設) // strategy: 'HIGH_PRICE_FIRST', // 先送高價品 id: 'GET_2_FREE', });
注意: value 是贈品數量,不是折扣金額。例如 new ItemGiveawayDiscount(2) 表示「送 2 件最低價品項」。
StepItemGiveawayDiscount (累積贈品折扣)
注意: StepItemGiveawayDiscount 目前未從 index.ts 導出,若需使用請直接從原始碼路徑 import。
// 目前無法從 @rytass/order-builder 直接 import // 需從原始碼路徑 import(若專案支援) import { StepItemGiveawayDiscount } from '@rytass/order-builder/src/policies/discount/step-item-giveaway-discount';
// 每買 3 件送 1 件(累積) // 例如:買 7 件 → 送 2 件 (floor(7/3) = 2) const discount = new StepItemGiveawayDiscount(3, 1, { id: 'BUY_3_GET_1', name: '每滿 3 件送 1 件', strategy: 'LOW_PRICE_FIRST', // 'LOW_PRICE_FIRST' | 'HIGH_PRICE_FIRST' });
// 計算公式: giveawayQuantity = value * floor(matchedQuantity / (step + value)) // stepLimit 計算: min(stepLimit, floor(matchedQuantity / (step + value)))
Conditions
PriceThreshold (金額閾值)
import { PriceThreshold } from '@rytass/order-builder';
// 滿 1000 元 const condition = new PriceThreshold(1000);
QuantityThreshold (數量閾值)
import { QuantityThreshold } from '@rytass/order-builder';
// 滿 5 件 const condition = new QuantityThreshold(5);
ItemIncluded (包含品項)
import { ItemIncluded } from '@rytass/order-builder';
// 購物車包含 A 或 B (基本用法) const condition = new ItemIncluded({ items: ['A', 'B'] });
// 指定數量閾值 const conditionWithThreshold = new ItemIncluded({ items: ['A', 'B'], threshold: 3, // 需要 A 或 B 合計至少 3 件 });
// 使用函式判斷 const conditionWithFn = new ItemIncluded({ isMatchedItem: (item) => item.category === 'special', threshold: 1, });
// 指定 scope (搜尋的屬性) const conditionWithScope = new ItemIncluded({ items: ['sku-001', 'sku-002'], scope: ['sku', 'id'], // 優先檢查 sku,沒有再檢查 id });
// 搭配子條件 const conditionWithSubConditions = new ItemIncluded({ items: ['A'], conditions: [new PriceThreshold(500)], // 包含 A 且小計需達 500 });
ItemRequired (必須包含)
import { ItemRequired } from '@rytass/order-builder';
// 購物車必須包含 A(數量至少 1) const conditionSimple = new ItemRequired('A');
// 購物車必須包含 A 且數量 >= 2(使用物件格式) const condition = new ItemRequired({ id: 'A', quantity: 2 });
// 批量需求:A 至少 2 件、B 至少 1 件 const conditionMultiple = new ItemRequired([ { id: 'A', quantity: 2 }, 'B', // 字串會自動轉為 { id: 'B', quantity: 1 } ]);
ItemExcluded (排除品項)
import { ItemExcluded } from '@rytass/order-builder';
// 排除特定品項(不參與此折扣) const condition = new ItemExcluded({ items: ['excluded-item-1', 'excluded-item-2'] });
// 使用函式判斷排除 const conditionWithFilter = new ItemExcluded({ isMatchedItem: (item) => item.category === 'special', });
// 指定 scope (搜尋的屬性) const conditionWithScope = new ItemExcluded({ items: ['sku-001'], scope: ['sku', 'id'], });
QuantityRequired (數量需求)
import { QuantityRequired } from '@rytass/order-builder';
// 指定品項總數量需達到 5 const condition = new QuantityRequired(5, ['item-a', 'item-b']);
// 不指定品項,計算全部品項數量 const conditionAll = new QuantityRequired(10);
CouponValidator (優惠券)
import { ValueDiscount, CouponValidator } from '@rytass/order-builder';
const couponDiscount = new ValueDiscount(50, { id: 'COUPON_50', conditions: [new CouponValidator('SAVE50')], });
// 使用優惠券 const order = builder.build({ items: [...], coupons: ['SAVE50'], });
Configuration Options
OrderBuilder Options
new OrderBuilder({ policies?: Policy[]; policyPickStrategy?: 'order-based' | 'item-based'; // 預設: 'item-based' discountMethod?: 'price-weighted-average' | 'quantity-weighted-average'; // 預設: 'price-weighted-average' roundStrategy?: RoundStrategyType | [RoundStrategyType, number]; // 預設: 'every-calculation' logistics?: OrderLogistics; });
Policy Pick Strategy(折扣選擇策略)
當訂單符合多個折扣政策時,決定如何選擇最佳折扣:
策略 預設 說明
item-based
✓ 針對每個品項分別選擇最佳折扣組合(最優解)
order-based
選擇整體折扣最高的單一政策
策略差異範例:
// 假設訂單有 A、B 兩品項 // 政策 1: A 折 50 元 // 政策 2: B 折 100 元
// item-based(預設): A 套用政策 1、B 套用政策 2 → 總折扣 150 // order-based: 只選擇政策 2 → 總折扣 100
new OrderBuilder({ policyPickStrategy: 'item-based', // 推薦,可獲得最大折扣 });
Discount Method(折扣分配方式)
決定如何將整體折扣分配到各品項:
方法 預設 說明
price-weighted-average
✓ 按金額加權分配折扣
quantity-weighted-average
按數量加權分配折扣
Round Strategy(四捨五入策略)
策略 預設 說明
every-calculation
✓ 每次計算都四捨五入
final-price-only
只在最終價格四捨五入
no-round
不四捨五入
精度設定:
new OrderBuilder({ roundStrategy: 'final-price-only', // 預設精度 0(整數) // 或指定精度 roundStrategy: ['final-price-only', 2], // 小數後 2 位 });
OrderLogistics(運費設定)
interface OrderLogistics { price: number; // 運費(必填) name?: string; // 物流名稱(可選) threshold?: number; // 免運門檻金額(可選) freeConditions?: Condition | Condition[]; // 免運條件(可選) }
運費範例:
import { OrderBuilder, PriceThreshold, ItemIncluded } from '@rytass/order-builder';
// 基本運費設定 new OrderBuilder({ logistics: { price: 60, name: '宅配', }, });
// 滿額免運 new OrderBuilder({ logistics: { price: 60, name: '宅配', threshold: 1000, // 滿 1000 免運 }, });
// 條件免運 new OrderBuilder({ logistics: { price: 60, name: '宅配', freeConditions: [ new PriceThreshold(1000), // 滿 1000 免運 new ItemIncluded(['vip-product']), // 或購買 VIP 商品免運 ], }, });
Order Methods
動態調整
const order = builder.build({ items: initialItems });
// 新增品項 (單一或多個) order.addItem({ id: 'C', name: 'Product C', unitPrice: 200, quantity: 1 }); order.addItem([ { id: 'D', name: 'Product D', unitPrice: 100, quantity: 2 }, { id: 'E', name: 'Product E', unitPrice: 150, quantity: 1 }, ]);
// 移除品項 - 多種方式 // 1. 依 ID 和數量移除 order.removeItem('A', 2); // 移除 A 商品 2 件
// 2. 依品項物件移除 order.removeItem({ id: 'B', name: 'Product B', unitPrice: 300, quantity: 1 });
// 3. 批次移除多個品項 order.removeItem([ { id: 'C', name: 'Product C', unitPrice: 200, quantity: 1 }, ]);
// 新增優惠券 order.addCoupon('SAVE50');
// 移除優惠券 order.removeCoupon('SAVE50');
子訂單
// 篩選特定品項的子訂單 const subOrder = order.subOrder({ subItems: ['A', 'B'], // 子訂單包含的品項 ID subCoupons: ['COUPON1'], // 子訂單使用的優惠券(可選) subPolicies: [new ValueDiscount(50)], // 子訂單額外政策(可選) itemScope: 'id', // 'id'(預設)或 'uuid',指定篩選依據 });
console.log(subOrder.price); // 只計算子訂單的價格 console.log(subOrder.parent); // 父訂單參照
Complete Example
import { OrderBuilder, ValueDiscount, PercentageDiscount, StepValueDiscount, PriceThreshold, ItemRequired, CouponValidator, } from '@rytass/order-builder';
// 定義折扣政策 const policies = [ // 全館 95 折 new PercentageDiscount(0.05, { id: 'GLOBAL_95', name: '全館 95 折', }),
// 每滿千折 50 (累積) new StepValueDiscount(1000, 50, { id: 'STEP_DISCOUNT', name: '每滿千折 50', }),
// 特定商品折扣 new ValueDiscount(100, { id: 'PRODUCT_A_DISCOUNT', name: '購買 A 商品折 100', conditions: [new ItemRequired('product-a', 1)], }),
// 優惠券折扣 new ValueDiscount(200, { id: 'COUPON_200', name: '優惠券折 200', conditions: [new CouponValidator('SAVE200')], }), ];
// 建立 Builder const builder = new OrderBuilder({ policies, discountMethod: 'price-weighted-average', roundStrategy: 'final-price-only', });
// 建立訂單 const order = builder.build({ items: [ { id: 'product-a', name: '商品 A', unitPrice: 1500, quantity: 1 }, { id: 'product-b', name: '商品 B', unitPrice: 800, quantity: 2 }, { id: 'product-c', name: '商品 C', unitPrice: 300, quantity: 3 }, ], coupons: ['SAVE200'], });
// 查看結果
console.log('商品總額:', order.itemValue);
console.log('折扣金額:', order.discountValue);
console.log('最終價格:', order.price);
console.log('應用的折扣:');
order.discounts.forEach(d => {
console.log( - ${d.name}: -${d.value});
});
// 動態調整 order.addItem({ id: 'product-d', name: '商品 D', unitPrice: 500, quantity: 1 }); console.log('新增商品後價格:', order.price);
order.removeCoupon('SAVE200'); console.log('移除優惠券後價格:', order.price);
Advanced Types
Exported Enums
// 折扣類型 enum Discount { PERCENTAGE = 'PERCENTAGE', VALUE = 'VALUE', STEP_VALUE = 'STEP_VALUE', STEP_PERCENTAGE = 'STEP_PERCENTAGE', ITEM_GIVEAWAY = 'ITEM_GIVEAWAY', STEP_ITEM_GIVEAWAY = 'STEP_ITEM_GIVEAWAY', }
// 政策前綴 enum PolicyPrefix { DISCOUNT = 'DISCOUNT', }
Core Types
// 訂單品項 interface OrderItem { id: string; name: string; unitPrice: number; quantity: number; conditionRef?: string | string[] | ((..._: unknown[]) => boolean); }
// 扁平化品項(quantity = 1,有獨立 uuid) interface FlattenOrderItem extends OrderItem { uuid: string; }
// 品項記錄(折扣結果) interface OrderItemRecord<Item extends OrderItem> { itemId: string; appliedPolicies: Policy[]; originItem: Item; initialValue: number; discountValue: number; finalPrice: number; discountRecords: ItemDiscountRecord[]; }
// 基本品項(最小化) interface BaseOrderItem { id: string; quantity: number; conditionRef?: string | string[] | ((..._: unknown[]) => boolean); }
Constants(常數)
import { ORDER_LOGISTICS_ID, ORDER_LOGISTICS_NAME } from '@rytass/order-builder';
// 運費品項的固定 ID ORDER_LOGISTICS_ID = 'LOGISTICS';
// 運費品項的預設名稱 ORDER_LOGISTICS_NAME = 'logistics';
Policy & Condition Interfaces
// 政策介面 interface Policy<T extends ObjRecord = ObjRecord> { id: string; prefix: PolicyPrefix; conditions?: Condition[]; matchedItems(order: Order): FlattenOrderItem[]; valid(order: Order): boolean; resolve<TT extends T>(order: Order, ...: unknown[]): TT[]; description(...: unknown[]): PolicyResult<T>; }
// 條件介面 type Condition<T extends ObjRecord = ObjRecord, Options extends ObjRecord = ObjRecord> = { satisfy(order: Order, ..._: unknown[]): boolean; matchedItems?: (order: Order) => FlattenOrderItem[]; readonly options?: Options; } & T;
// 折扣政策描述結果 interface PolicyDiscountDescription { id: string; type: Discount; value: number; discount: number; conditions: Condition[]; appliedItems: FlattenOrderItem[]; matchedTimes: number; policy: BaseDiscount; }
Discount Options Types
// 基本折扣選項 interface DiscountOptions { id?: string; name?: string; conditions?: Condition[]; onlyMatched?: boolean; excludedInCalculation?: boolean; }
// Step 折扣選項 interface StepDiscountOptions extends DiscountOptions { stepUnit: 'quantity' | 'price'; stepLimit?: number; }
// 贈品策略 type ItemGiveawayStrategy = 'LOW_PRICE_FIRST' | 'HIGH_PRICE_FIRST';
Dependencies
- decimal.js ^10.6.0 (精確計算)
Troubleshooting
折扣未生效
檢查條件是否滿足:
// 確認優惠券已加入 order.addCoupon('COUPON_CODE');
// 確認品項符合條件 // ItemRequired('A', 2) 需要品項 A 數量 >= 2
金額計算不精確
使用正確的 roundStrategy:
new OrderBuilder({ roundStrategy: 'final-price-only', // 推薦 });
多重折扣衝突
預設使用 item-based 策略,會自動選擇每個品項的最佳折扣組合。
如需改為「整體擇優」模式(只選一個最高折扣政策):
new OrderBuilder({ policyPickStrategy: 'order-based', });