Order calculation and discount engine. Use when building shopping carts, applying discounts, performing pricing calculations, or implementing coupon systems. Supports precise decimal arithmetic using Decimal.js. Keywords: order, cart, discount, coupon, pricing, Decimal.js
@rytass/order-builder 提供訂單計算與優惠政策應用引擎,支援複雜折扣邏輯、多階梯優惠和精確計算(Decimal.js)。
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); // 最終價格
const builder = new OrderBuilder()
.addPolicy(new ValueDiscount(100))
.addPolicy(new PercentageDiscount(0.1));
// build() 後政策鎖定
const order = builder.build({ items: [...] });
order.price // 最終價格
order.discountValue // 總折扣額
order.itemValue // 商品總額
order.itemQuantity // 商品總數量
order.discounts // 折扣說明陣列
order.itemRecords // 品項記錄
order.logisticsRecord // 運費記錄(若有設定運費)
order.parent // 父訂單(若為子訂單)
所有折扣類別都支援以下選項:
interface DiscountOptions {
id?: string; // 折扣識別碼
onlyMatched?: boolean; // 只計算符合條件的品項(預設: false)
excludedInCalculation?: boolean; // 此折扣不計入折扣總額(預設: false)
}
注意:
conditions是作為獨立的構造函數參數傳入,而非在 options 物件中。 自訂屬性(如name)可透過泛型類型參數DiscountOptions<T>傳入。
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
});
import { PercentageDiscount } from '@rytass/order-builder';
// 9 折 (折 10%)
const discount = new PercentageDiscount(0.1, {
id: 'DISCOUNT_10',
name: '全館 9 折',
});
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 金額就累積一次折扣,不是「階梯式」選擇最高折扣。
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 折扣率。
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目前未從 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)))
import { PriceThreshold } from '@rytass/order-builder';
// 滿 1000 元
const condition = new PriceThreshold(1000);
import { QuantityThreshold } from '@rytass/order-builder';
// 滿 5 件
const condition = new QuantityThreshold(5);
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
});
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 }
]);
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'],
});
import { QuantityRequired } from '@rytass/order-builder';
// 指定品項總數量需達到 5
const condition = new QuantityRequired(5, ['item-a', 'item-b']);
// 不指定品項,計算全部品項數量
const conditionAll = new QuantityRequired(10);
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'],
});
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;
});
當訂單符合多個折扣政策時,決定如何選擇最佳折扣:
| 策略 | 預設 | 說明 |
|------|------|------|
| 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', // 推薦,可獲得最大折扣
});
決定如何將整體折扣分配到各品項:
| 方法 | 預設 | 說明 |
|------|------|------|
| price-weighted-average | ✓ | 按金額加權分配折扣 |
| quantity-weighted-average | | 按數量加權分配折扣 |
| 策略 | 預設 | 說明 |
|------|------|------|
| every-calculation | ✓ | 每次計算都四捨五入 |
| final-price-only | | 只在最終價格四捨五入 |
| no-round | | 不四捨五入 |
精度設定:
new OrderBuilder({
roundStrategy: 'final-price-only', // 預設精度 0(整數)
// 或指定精度
roundStrategy: ['final-price-only', 2], // 小數後 2 位
});
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 商品免運
],
},
});
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); // 父訂單參照
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);
// 折扣類型
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',
}
// 訂單品項
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);
}
import { ORDER_LOGISTICS_ID, ORDER_LOGISTICS_NAME } from '@rytass/order-builder';
// 運費品項的固定 ID
ORDER_LOGISTICS_ID = '__LOGISTICS__';
// 運費品項的預設名稱
ORDER_LOGISTICS_NAME = 'logistics';
// 政策介面
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;
}
// 基本折扣選項
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';
decimal.js ^10.6.0 (精確計算)檢查條件是否滿足:
// 確認優惠券已加入
order.addCoupon('COUPON_CODE');
// 確認品項符合條件
// ItemRequired('A', 2) 需要品項 A 數量 >= 2
使用正確的 roundStrategy:
new OrderBuilder({
roundStrategy: 'final-price-only', // 推薦
});
預設使用 item-based 策略,會自動選擇每個品項的最佳折扣組合。
如需改為「整體擇優」模式(只選一個最高折扣政策):
new OrderBuilder({
policyPickStrategy: 'order-based',
});
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer