| name | order-builder |
| description | Order calculation and discount engine (訂單計算與優惠引擎). Use when building shopping carts (購物車), applying discounts (折扣), pricing calculations (價格計算), or coupon systems (優惠券系統). Supports precise decimal calculations with Decimal.js. Keywords: 訂單, 購物車, 折扣, 優惠, 計算, order, cart, discount, coupon, pricing, Decimal.js
|
Order Builder (訂單計算引擎)
Overview
@rytass/order-builder 提供訂單計算與優惠政策應用引擎,支援複雜折扣邏輯、多階梯優惠和精確計算(Decimal.js)。
Quick Start
安裝
npm install @rytass/order-builder
基本使用
import { OrderBuilder, ValueDiscount, PercentageDiscount } from '@rytass/order-builder';
const builder = new OrderBuilder({
policies: [
new ValueDiscount(100),
new PercentageDiscount(0.1),
],
});
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);
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));
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;
excludedInCalculation?: boolean;
}
注意: conditions 是作為獨立的構造函數參數傳入,而非在 options 物件中。
自訂屬性(如 name)可透過泛型類型參數 DiscountOptions<T> 傳入。
ValueDiscount (固定額折扣)
import { ValueDiscount } from '@rytass/order-builder';
const discount = new ValueDiscount(100, {
id: 'FLAT_100',
name: '滿額折 100',
});
const advancedDiscount = new ValueDiscount(50, {
id: 'SPECIAL_50',
onlyMatched: true,
excludedInCalculation: true,
});
PercentageDiscount (百分比折扣)
import { PercentageDiscount } from '@rytass/order-builder';
const discount = new PercentageDiscount(0.1, {
id: 'DISCOUNT_10',
name: '全館 9 折',
});
StepValueDiscount (累積固定額折扣)
import { StepValueDiscount } from '@rytass/order-builder';
const discount = new StepValueDiscount(1000, 100, {
id: 'STEP_DISCOUNT',
name: '每滿千折百',
});
const discountWithLimit = new StepValueDiscount(500, 50, {
id: 'LIMITED_STEP',
stepLimit: 3,
stepUnit: 'price',
onlyMatched: true,
});
注意: 這是「累積折扣」模式,每達到 step 金額就累積一次折扣,不是「階梯式」選擇最高折扣。
StepPercentageDiscount (累積百分比折扣)
import { StepPercentageDiscount } from '@rytass/order-builder';
const discount = new StepPercentageDiscount(3, 0.95, {
id: 'QUANTITY_DISCOUNT',
name: '每 3 件折 5%',
stepUnit: 'quantity',
});
const priceBasedDiscount = new StepPercentageDiscount(1000, 0.95, {
id: 'PRICE_STEP',
name: '每滿千折 5%',
stepUnit: 'price',
stepLimit: 5,
});
注意: 這是「累積複利折扣」模式,每達到 step 就額外乘以 value 折扣率。
ItemGiveawayDiscount (贈品折扣)
import { ItemGiveawayDiscount, ItemIncluded } from '@rytass/order-builder';
const discount = new ItemGiveawayDiscount(1, new ItemIncluded({ items: ['A'] }), {
id: 'BUY_A_GET_FREE',
});
new ItemGiveawayDiscount(1, [new ItemIncluded({ items: ['A'] })], { id: 'GIVEAWAY_1' });
new ItemGiveawayDiscount(1, new ItemIncluded({ items: ['A'] }), { id: 'GIVEAWAY_2' });
new ItemGiveawayDiscount(1, { id: 'FREE_ONE', strategy: 'HIGH_PRICE_FIRST' });
new ItemGiveawayDiscount(1);
const discountWithStrategy = new ItemGiveawayDiscount(2, new ItemIncluded({ items: ['B', 'C'] }), {
strategy: 'LOW_PRICE_FIRST',
id: 'GET_2_FREE',
});
注意: value 是贈品數量,不是折扣金額。例如 new ItemGiveawayDiscount(2) 表示「送 2 件最低價品項」。
StepItemGiveawayDiscount (累積贈品折扣)
注意: StepItemGiveawayDiscount 目前未從 index.ts 導出,若需使用請直接從原始碼路徑 import。
import { StepItemGiveawayDiscount } from '@rytass/order-builder/src/policies/discount/step-item-giveaway-discount';
const discount = new StepItemGiveawayDiscount(3, 1, {
id: 'BUY_3_GET_1',
name: '每滿 3 件送 1 件',
strategy: 'LOW_PRICE_FIRST',
});
Conditions
PriceThreshold (金額閾值)
import { PriceThreshold } from '@rytass/order-builder';
const condition = new PriceThreshold(1000);
QuantityThreshold (數量閾值)
import { QuantityThreshold } from '@rytass/order-builder';
const condition = new QuantityThreshold(5);
ItemIncluded (包含品項)
import { ItemIncluded } from '@rytass/order-builder';
const condition = new ItemIncluded({ items: ['A', 'B'] });
const conditionWithThreshold = new ItemIncluded({
items: ['A', 'B'],
threshold: 3,
});
const conditionWithFn = new ItemIncluded({
isMatchedItem: (item) => item.category === 'special',
threshold: 1,
});
const conditionWithScope = new ItemIncluded({
items: ['sku-001', 'sku-002'],
scope: ['sku', 'id'],
});
const conditionWithSubConditions = new ItemIncluded({
items: ['A'],
conditions: [new PriceThreshold(500)],
});
ItemRequired (必須包含)
import { ItemRequired } from '@rytass/order-builder';
const conditionSimple = new ItemRequired('A');
const condition = new ItemRequired({ id: 'A', quantity: 2 });
const conditionMultiple = new ItemRequired([
{ id: 'A', quantity: 2 },
'B',
]);
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',
});
const conditionWithScope = new ItemExcluded({
items: ['sku-001'],
scope: ['sku', 'id'],
});
QuantityRequired (數量需求)
import { QuantityRequired } from '@rytass/order-builder';
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';
discountMethod?: 'price-weighted-average' | 'quantity-weighted-average';
roundStrategy?: RoundStrategyType | [RoundStrategyType, number];
logistics?: OrderLogistics;
});
Policy Pick Strategy(折扣選擇策略)
當訂單符合多個折扣政策時,決定如何選擇最佳折扣:
| 策略 | 預設 | 說明 |
|---|
item-based | ✓ | 針對每個品項分別選擇最佳折扣組合(最優解) |
order-based | | 選擇整體折扣最高的單一政策 |
策略差異範例:
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',
roundStrategy: ['final-price-only', 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,
},
});
new OrderBuilder({
logistics: {
price: 60,
name: '宅配',
freeConditions: [
new PriceThreshold(1000),
new ItemIncluded(['vip-product']),
],
},
});
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 },
]);
order.removeItem('A', 2);
order.removeItem({ id: 'B', name: 'Product B', unitPrice: 300, quantity: 1 });
order.removeItem([
{ id: 'C', name: 'Product C', unitPrice: 200, quantity: 1 },
]);
order.addCoupon('SAVE50');
order.removeCoupon('SAVE50');
子訂單
const subOrder = order.subOrder({
subItems: ['A', 'B'],
subCoupons: ['COUPON1'],
subPolicies: [new ValueDiscount(50)],
itemScope: 'id',
});
console.log(subOrder.price);
console.log(subOrder.parent);
Complete Example
import {
OrderBuilder,
ValueDiscount,
PercentageDiscount,
StepValueDiscount,
PriceThreshold,
ItemRequired,
CouponValidator,
} from '@rytass/order-builder';
const policies = [
new PercentageDiscount(0.05, {
id: 'GLOBAL_95',
name: '全館 95 折',
}),
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')],
}),
];
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);
}
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';
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;
}
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');
金額計算不精確
使用正確的 roundStrategy:
new OrderBuilder({
roundStrategy: 'final-price-only',
});
多重折扣衝突
預設使用 item-based 策略,會自動選擇每個品項的最佳折扣組合。
如需改為「整體擇優」模式(只選一個最高折扣政策):
new OrderBuilder({
policyPickStrategy: 'order-based',
});