ECサイトにおける販促（値引やセット価格）の処理モデルを考えてみる

EC の販促施策は様々な種類が考えられるため、柔軟な処理モデルが求められそうです。

そこで、商品の値引やセット価格等の販促を処理するためのモデルを考えてみました。

# はじめに

販促自体に関しては、指定の条件を満たした場合に何らかの報酬が得られるものだと考えると、以下のようなモデルが考えられそうです。

```sh:販促モデル例
販促 = (条件, 報酬計算)
```

```報酬計算``` の部分をより汎用的にすると、一般的なビジネスルールエンジンのモデルと合致するので、考え方としては悪く無さそうです。

```sh:一般的なビジネスルールエンジンのモデル例
ビジネスルール = (条件, アクション)
```

販促の条件に関しては以下のように色々と考えられます。

* 指定の商品を購入
* 会員が特定の条件に合致
    * 会員ランク
    * 指定期間内の累計購入金額
* 購入金額が指定金額以上
* 指定の支払方法を利用
* 特定の日にちや曜日
* 販促の適用期間内
* 複数条件の組み合わせ

報酬も色々と考えられますが、主に以下が挙げられると思います。

* 値引・割引
    * 商品
    * 手数料（送料無料とか）
* セット価格（商品の組み合わせに対して特別な値引き価格を適用）
* ポイント
* クーポン
* ノベルティ（無料の商品）

処理的に、クーポンやノベルティは対象のIDやコードを返すだけ、ポイントは値引系と大差は無さそうなので、値引・割引とセット価格にフォーカスした処理モデルを考える事にしました。

また、EC の販促は主に以下の 2種類に分類できそうですが、前者のみを対象とします。

|タイプ|例|
|---|---|
|注文等の取引で適用する|商品の値引、送料無料など|
|取引以外で適用する|新規会員登録でポイント付与、会員の誕生日にクーポンやポイント付与など|

# 値引・割引とセット価格の処理モデル

まずは、ありそうな値引や価格変更のパターンをいくつか列挙してみました。（BOGO以外の名称は適当です）

|名称|例|
|---|---|
|単品値引・割引|対象商品 10%OFF、対象商品 100円引など|
|BOGO (Buy One Get One)|対象商品 1点購入で 2点目が無料（半額のケースもあり）|
|セット値引・割引|対象商品 2点以上購入で 20%OFF、対象商品 3点以上で 500円引、対象商品 2点以上で 2点目以降を 30%OFFなど|
|セット価格|対象商品 A と B 同時購入で 800円、対象商品 2点で 500円など|

これらにそれぞれ含まれている要素を分解して考えてみる事にします。

なお、以後は本件の処理を総称して "ディスカウント" と呼ぶ事にします。

## ディスカウント方法

ディスカウントの具体的な方法として 3通りが考えられます。

|名称|概要|例|
|---|---|---|
|値引（ValueDiscount）|指定の額で値引|100円引|
|割引（RateDiscount）|指定の割合で値引|10%OFF|
|価格変更（ChangePrice）|特別価格を適用|セット価格 500円|

単品への適用も考慮してセット価格ではなく ```価格変更``` と表現しました。

## ディスカウント適用処理

セットと単品の違いを対象商品のグループに対するアプローチの違いだと考えてみると、次のように分けられそうです。

|名称|概要|
|---|---|
|全体（Whole）|対象グループそのものに対してディスカウントを適用。セット系を実現|
|個別（Each）|対象グループの個々の要素に対してディスカウントを適用。単品系を実現|

これらをディスカウント方法と組み合わせる事で主要なものは概ね表現できそうな気がします。

また、Each で skip（ディスカウント対象としない数）と take（対象とする最大数）の数を指定できれば、```2点目以降で最大 3点まで値引を適用``` のような事も表現できると思います。

## モデル実装例

上記の内容と（最低限必要そうな）対象グループの選択条件を加えて Rust のコードで表現するとこのようになりました。

いわゆる代数的データ型（ADT）を採用していますが、上記のような考えをそのままモデルに落とし込めるので非常に有用だと考えます。

```rust:ディスカウント処理モデル例
type Quantity = usize;
type Amount = BigRational;

// ディスカウント方法
enum DiscountMethod {
    ValueDiscount(Amount),
    RateDiscount(Amount),
    ChangePrice(Amount),
}

// ディスカウント適用処理
enum DiscountAction {
    Whole(DiscountMethod),
    // Option<Quantity> は skip と take
    Each(DiscountMethod, Option<Quantity>, Option<Quantity>),
}

// 対象商品条件
enum ItemCondition {
    Item(Vec<ItemId>),
    Attribute(AttrKey, Vec<AttrValue>), // 商品属性の条件
    PriceRange(Amount, Option<Amount>), // 価格帯の条件
    Not(Box<ItemCondition>),
    And(Box<ItemCondition>, Box<ItemCondition>),
    Or(Box<ItemCondition>, Box<ItemCondition>),
}

// 対象グループの選択条件
enum GroupCondition {
    Items(ItemCondition), // 商品条件の設定
    QtyLimit(Box<GroupCondition>, Quantity, Option<Quantity>), // 商品点数の条件設定
    PickOne(Vec<ItemCondition>), // 商品を 1点ずつピックアップするための条件設定
}

// ディスカウントルール
struct DiscountRule {
    condition: GroupCondition,
    action: DiscountAction,
}
```

名称に関しては再考の余地ありだと思いますが（条件系も改善の余地があるかも）、モデル的にはシンプルでそう悪くはないのかなと思います。

# サンプル実装

考えたモデルをベースに各種処理を実装してみました。（完全なコードは [こちら](https://github.com/fits/try_samples/tree/master/blog/20240818/sales_promotion_discount)）

```rust:ディスカウント処理モデル実装例（models.rs）
...省略
#[derive(Debug, Clone, PartialEq)]
pub struct OrderItem {
    pub id: OrderItemId,
    pub item_id: ItemId,
    pub price: Amount,
    pub attrs: Attrs,
}

...省略

impl ItemCondition {
    pub fn not(&self) -> Self {
        Self::Not(Box::new(self.to_owned()))
    }

    pub fn and(&self, c: Self) -> Self {
        Self::And(Box::new(self.to_owned()), Box::new(c))
    }

    pub fn or(&self, c: Self) -> Self {
        Self::Or(Box::new(self.to_owned()), Box::new(c))
    }

    fn predict(&self, target: &OrderItem) -> bool {
        match self {
            Self::Item(items) => items.contains(&target.item_id),
            Self::Attribute(k, v) => target.attrs.get(k).map(|x| v.contains(x)).unwrap_or(false),
            Self::PriceRange(from, to) => {
                target.price >= *from && to.clone().map(|x| target.price <= x).unwrap_or(true)
            }
            Self::Not(c) => !c.predict(target),
            Self::And(c1, c2) => c1.predict(target) && c2.predict(target),
            Self::Or(c1, c2) => c1.predict(target) || c2.predict(target),
        }
    }
}

...省略

impl GroupCondition {
    pub fn qty_limit(&self, from: Quantity, to: Option<Quantity>) -> Self {
        Self::QtyLimit(Box::new(self.to_owned()), from, to)
    }

    fn select<'a>(&self, items: &'a Vec<OrderItem>) -> Option<Vec<&'a OrderItem>> {
        match self {
            Self::Items(c) => {
                let rs = items
                    .iter()
                    .filter(move |&x| c.predict(x))
                    .collect::<Vec<_>>();

                if rs.len() > 0 {
                    Some(rs)
                } else {
                    None
                }
            }
            Self::QtyLimit(c, from, to) => c
                .select(items)
                .map(|x| {
                    if let Some(to) = to {
                        x.into_iter().take(*to).collect::<Vec<_>>()
                    } else {
                        x
                    }
                })
                .and_then(|x| {
                    if x.len() >= max(1, *from) {
                        Some(x)
                    } else {
                        None
                    }
                }),
            Self::PickOne(cs) => {
                ...省略
            }
        }
    }
}
// ディスカウント結果（報酬内容）
#[derive(Debug, Clone)]
pub enum Reward<T> {
    GroupDiscount(Amount, Vec<T>, DiscountMethod),
    ItemDiscount(Vec<(Option<Amount>, T)>, DiscountMethod),
    GroupPrice(Amount, Vec<T>, DiscountMethod),
    ItemPrice(Vec<(Option<Amount>, T)>, DiscountMethod),
}

...省略

impl DiscountAction {
    ...省略

    // ディスカウントの適用
    fn action<'a>(&self, items: Vec<&'a OrderItem>) -> Option<Reward<&'a OrderItem>> {
        match self {
            Self::Whole(m) => match m {
                DiscountMethod::ValueDiscount(v) => {
                    let v = subtotal(&items).min(v.clone());

                    if v > Amount::zero() {
                        Some(Reward::GroupDiscount(v, items, m.clone()))
                    } else {
                        None
                    }
                }
                DiscountMethod::RateDiscount(r) => {
                    let total = subtotal(&items);
                    let d = total * r;

                    if d > Amount::zero() {
                        Some(Reward::GroupDiscount(d, items, m.clone()))
                    } else {
                        None
                    }
                }
                DiscountMethod::ChangePrice(p) => {
                    let total = subtotal(&items);
                    let price = p.clone().max(Amount::zero());

                    if total > price {
                        Some(Reward::GroupPrice(price, items, m.clone()))
                    } else {
                        None
                    }
                }
            },
            Self::Each(m, skip, take) => {
                let skip = skip.unwrap_or(0);

                if items.len() > skip {
                    match m {
                        DiscountMethod::ValueDiscount(v) => {
                            ...省略
                        }
                        DiscountMethod::RateDiscount(r) => {
                            ...省略
                        }
                        DiscountMethod::ChangePrice(p) => {
                            ...省略
                        }
                    }
                } else {
                    None
                }
            }
        }
    }
}

...省略

impl DiscountRule {
    // ディスカウントの適用
    pub fn apply<'a>(&self, items: &'a Vec<OrderItem>) -> Option<Reward<&'a OrderItem>> {
        self.condition
            .select(items)
            .and_then(|x| self.action.action(x))
    }
}
```

処理内容としては、与えられた OrderItem のリストからディスカウント対象のグループを選出して報酬を計算するようになっています。

なお、消費税の扱いや端数処理（切り上げ等）やディスカウント結果（報酬）の採否等は呼び出し側（例えばカートや注文処理）に委ねる事を前提としています。

## 検証

以下のように実行してディスカウントの販促をいくつか処理してみます。

```rust:処理内容
fn main() {
    // 注文商品構成
    let items = vec![
        order_item("o1", "item1", to_amount(1100), "A1", "Brand1"),
        order_item("o2", "item1", to_amount(1100), "A1", "Brand1"),
        order_item("o3", "item1", to_amount(1100), "A1", "Brand1"),
        order_item("o4", "item2", to_amount(2200), "B2", "Brand2"),
        order_item("o5", "item2", to_amount(2200), "B2", "Brand2"),
        order_item("o6", "item3", to_amount(3300), "C3", "Brand1"),
        order_item("o7", "item4", to_amount(4400), "A1", "Brand3"),
        order_item("o8", "item4", to_amount(4400), "A1", "Brand3"),
        order_item("o9", "item5", to_amount(5500), "C3", "Brand3"),
    ];
    
    let rule = DiscountRule {
        condition: ...省略,
        action: ...省略,
    };
    
    println!("{:?}", rule.apply(&items));
}

fn to_amount(v: usize) -> Amount {
    Amount::from_integer(v.into())
}

fn order_item(
    id: &'static str,
    item_id: &'static str,
    price: Amount,
    category: &'static str,
    brand: &'static str,
) -> OrderItem {
    OrderItem {
        id: id.into(),
        item_id: item_id.into(),
        price,
        attrs: HashMap::from([
            ("category".into(), category.into()),
            ("brand".into(), brand.into()),
        ]),
    }
}
```

### 単品割引

ブランドが ```Brand2```、もしくはカテゴリが ```C3``` の商品を個別に ```15% OFF``` するディスカウントルールは次のようになります。

```rust:単品値引例
DiscountRule {
    condition: Items(
        Attribute("brand".into(), vec!["Brand2".into()])
            .or(Attribute("category".into(), vec!["C3".into()])),
    ),
    action: DiscountAction::each(DiscountMethod::rate(to_amount(15))),
};
```

実行結果はこのように、o4 と o5 の値引額が ```330```、o6 は ```495```、o9 は ```825``` とそれぞれ price の 15% となりました。

```rust:結果
Some(ItemDiscount(
    [
        (Some(Ratio { numer: 330, denom: 1 }), OrderItem { id: "o4", item_id: "item2", price: Ratio { numer: 2200, denom: 1 }, attrs: {"brand": "Brand2", "category": "B2"} }), 
        (Some(Ratio { numer: 330, denom: 1 }), OrderItem { id: "o5", item_id: "item2", price: Ratio { numer: 2200, denom: 1 }, attrs: {"brand": "Brand2", "category": "B2"} }), 
        (Some(Ratio { numer: 495, denom: 1 }), OrderItem { id: "o6", item_id: "item3", price: Ratio { numer: 3300, denom: 1 }, attrs: {"category": "C3", "brand": "Brand1"} }), 
        (Some(Ratio { numer: 825, denom: 1 }), OrderItem { id: "o9", item_id: "item5", price: Ratio { numer: 5500, denom: 1 }, attrs: {"brand": "Brand3", "category": "C3"} })
    ], 
    RateDiscount(Ratio { numer: 3, denom: 20 })
))
```

### BOGO (Buy One Get One)

```item1``` の 1点目は値引なしで 2点目を無料にするディスカウントルールはこのようになります。

```rust:BOGO例1
DiscountRule {
    condition: Items(Item(vec!["item1".into()])).qty_limit(2, Some(2)),
    action: DiscountAction::each_with_skip(DiscountMethod::rate(to_amount(100)), 1),
};
```

結果は、1点目は値引なし（```None```）で 2点目の値引額が ```1100```（price と同値なので無料となる）となりました。

```rust:結果1
Some(ItemDiscount(
    [
        (None, OrderItem { id: "o1", item_id: "item1", price: Ratio { numer: 1100, denom: 1 }, attrs: {"category": "A1", "brand": "Brand1"} }), 
        (Some(Ratio { numer: 1100, denom: 1 }), OrderItem { id: "o2", item_id: "item1", price: Ratio { numer: 1100, denom: 1 }, attrs: {"brand": "Brand1", "category": "A1"} })
    ], 
    RateDiscount(Ratio { numer: 1, denom: 1 })
))
```

また、次のようにする事でも実現可能です。（こちらは 2点目を半額にしています）

```rust:BOGO例2
DiscountRule {
    condition: PickOne(vec![Item(vec!["item1".into()]), Item(vec!["item1".into()])]),
    action: DiscountAction::each_with_skip(DiscountMethod::rate(to_amount(50)), 1),
};
```

2点目が ```550``` 値引となりました。

```rust:結果2
Some(ItemDiscount(
    [
        (None, OrderItem { id: "o1", item_id: "item1", price: Ratio { numer: 1100, denom: 1 }, attrs: {"category": "A1", "brand": "Brand1"} }), 
        (Some(Ratio { numer: 550, denom: 1 }), OrderItem { id: "o2", item_id: "item1", price: Ratio { numer: 1100, denom: 1 }, attrs: {"brand": "Brand1", "category": "A1"} })
    ], 
    RateDiscount(Ratio { numer: 1, denom: 2 })
))
```

### セット値引

```Brand1``` 以外 2点以上のセットで ```1000``` 値引の場合。

```rust:セット値引例
DiscountRule {
    condition: Items(Attribute("brand".into(), vec!["Brand1".into()]).not()).qty_limit(2, None),
    action: DiscountAction::Whole(DiscountMethod::value(to_amount(1000))),
};
```

対象グループ全体に対して ```1000``` 値引となりました。

```rust:結果
Some(GroupDiscount(
    Ratio { numer: 1000, denom: 1 }, 
    [
        OrderItem { id: "o4", item_id: "item2", price: Ratio { numer: 2200, denom: 1 }, attrs: {"brand": "Brand2", "category": "B2"} }, 
        OrderItem { id: "o5", item_id: "item2", price: Ratio { numer: 2200, denom: 1 }, attrs: {"brand": "Brand2", "category": "B2"} }, 
        OrderItem { id: "o7", item_id: "item4", price: Ratio { numer: 4400, denom: 1 }, attrs: {"brand": "Brand3", "category": "A1"} }, 
        OrderItem { id: "o8", item_id: "item4", price: Ratio { numer: 4400, denom: 1 }, attrs: {"category": "A1", "brand": "Brand3"} }, 
        OrderItem { id: "o9", item_id: "item5", price: Ratio { numer: 5500, denom: 1 }, attrs: {"brand": "Brand3", "category": "C3"} }
    ], 
    ValueDiscount(Ratio { numer: 1000, denom: 1 })
))
```

### セット価格

カテゴリが ```A1``` と ```B2``` の組み合わせでセット価格 ```2500``` の場合。

```rust:セット価格例
DiscountRule {
    condition: PickOne(vec![
        Attribute("category".into(), vec!["A1".into()]),
        Attribute("category".into(), vec!["B2".into()]),
    ]),
    action: DiscountAction::Whole(DiscountMethod::price(to_amount(2500))),
};
```

o1 と o4 のセットで価格が ```2500``` となりました。

```rust:結果
Some(GroupPrice(
    Ratio { numer: 2500, denom: 1 }, 
    [
        OrderItem { id: "o1", item_id: "item1", price: Ratio { numer: 1100, denom: 1 }, attrs: {"category": "A1", "brand": "Brand1"} }, 
        OrderItem { id: "o4", item_id: "item2", price: Ratio { numer: 2200, denom: 1 }, attrs: {"category": "B2", "brand": "Brand2"} }
    ], 
    ChangePrice(Ratio { numer: 2500, denom: 1 })
))
```