セット割引をルールエンジンで処理 - Drools 使用

カテゴリ A の商品 1点とカテゴリ B の商品 1点の同時購入でセット価格が適用されるといったようなセット割引(セット商品割引)処理をルールエンジン Drools で実装してみました。

使用した環境は id:fits:20120104、id:fits:20120105 と同じです。

サンプルソースは http://github.com/fits/try_samples/tree/master/blog/20120122/

処理内容

セット割引を実現するためのクラス構成はいろいろと考えられますが、今回は商品クラス(Product)のサブクラスとしてセット商品クラス(SetProduct)を用意し、SetProduct に割引適用後の金額を設定するようにしました。
また、ルールエンジンの処理用に OrderParameter クラス(商品 1点毎に 1インスタンス)を用意しました。

処理内容は以下の通りです。

  1. 注文商品 1点毎に OrderParameter を作成し StatefulKnowledgeSession に insert
  2. ルールの適用
    1. セットのルールにマッチした場合はセット商品 SetProduct を作成し、セット商品と単品商品に関わらず注文明細 OrderItem を作成 (セット割引)
    2. 注文明細の合計金額等に対して全体的な割引を適用 (ボリュームディスカウント
  3. 結果出力

注文明細の合計金額等に対して適用するような処理(ボリュームディスカウント)はセット商品の抽出が全て完了した後で実施する必要があるため、Drools のアジェンダグループの機能を使います。

ルール定義(DRL)

今回は以下のようなルール定義を行います。

  • セット割引(agenda-group = 注文明細)
    • A 1点 + B 1点でセット価格 5千円
    • B 1点 + C 1点 + 何でも 1点でセット割引 20%OFF
    • D以外 2点で 10%OFF
  • ボリュームディスカウント(agenda-group = 注文)
    • 購入金額 3万5千円以上で 20% OFF
    • セット商品が 5セット以上ある場合に購入金額を 10% OFF

ここでボリュームディスカウントは 2つ同時に適用されない事とします。

主な特徴は以下の通りです。

  • アジェンダグループ agenda-group を使って段階的なルール適用を実施
  • this not in (・・・) や this != ・・・ を使って同一 OrderParameter インスタンスの複数マッチを防止
  • from を使って注文明細から条件にマッチするものを抽出
  • from accumulate を使って明細の金額やセット商品の組み合わせ数を集計

agenda-group を指定する事で、同じアジェンダグループ内のルール適用が全て完了してからでないと他のアジェンダグループのルール適用が行われなくなります。なお、適用するアジェンダグループの指定はプログラム側で行います。

"B 1点 + C 1点 + 何でも 1点でセット割引 20%OFF" では、$item1 や $item2 にマッチしたインスタンスが $item3 にマッチするのを防ぐために this not in ($item1, $item2) を使っています。("D以外 2点で 10%OFF" の this != $item1 も同様)

from でリスト内の条件に合致したものを抽出できるので、指定の商品(単品のみ)が既に注文明細として追加されていれば新しい明細を作成せずにその明細の注文数をカウントアップするようにしています。

また、from accumulate(集計対象, 集計式) で簡易な集計処理を実施できるので(集計式には sum, count, average, min, max 等の集計関数が利用できる)、合計金額やセット組み数の集計に利用しました。

setdiscount.drl
//関数定義(dialect や rule より先に宣言する必要あり)
function void println(String msg) {
    System.out.println(msg);
}

dialect "mvel"

rule "A 1点 + B 1点でセット価格 5千円"
    agenda-group "注文明細"
    no-loop
    salience 30
    when
        $order : Order()
        $item1 : OrderParameter(product.category == "A", done == false)
        $item2 : OrderParameter(product.category == "B", done == false)
    then
        $setProduct = new SetProduct("セット1", "A 1点 + B 1点", 5000, [$item1.getProduct(), $item2.getProduct()])

        //セット総数を "注文" アジェンダグループで集計するので insert しておく
        insert($setProduct)

        //注文明細の作成と追加
        $order.getItemList().add(new OrderItem($setProduct))

        $item1.setDone(true)
        $item2.setDone(true)

        update($order)
        update($item1)
        update($item2)
end

rule "B 1点 + C 1点 + 何でも 1点でセット割引 20%OFF"
    agenda-group "注文明細"
    no-loop
    salience 20
    when
        $order : Order()
        $item1 : OrderParameter(product.category == "B", done == false, $product1 : product)
        $item2 : OrderParameter(product.category == "C", done == false, $product2 : product)
        $item3 : OrderParameter(done == false, this not in ($item1, $item2), $product3 : product)
    then
        $setPrice = ($product1.getPrice() + $product2.getPrice() + $product3.getPrice()) * 0.8
        $setProduct = new SetProduct("セット2", "B 1点 + C 1点 + 何でも 1点", $setPrice, [$product1, $product2, $product3])

        //セット総数を "注文" アジェンダグループで集計するので insert しておく
        insert($setProduct)

        //注文明細の作成と追加
        $order.getItemList().add(new OrderItem($setProduct))

        $item1.setDone(true)
        $item2.setDone(true)
        $item3.setDone(true)

        update($order)
        update($item1)
        update($item2)
        update($item3)
end

rule "D以外 2点で 10%OFF"
    agenda-group "注文明細"
    no-loop
    salience 10
    when
        $order : Order()
        $item1 : OrderParameter(product.category != "D", done == false, $product1 : product)
        $item2 : OrderParameter(product.category != "D", done == false, this != $item1, $product2 : product)
    then
        $setPrice = ($product1.getPrice() + $product2.getPrice()) * 0.9
        $setProduct = new SetProduct("セット3", "D以外 2点", $setPrice, [$product1, $product2])

        //セット総数を "注文" アジェンダグループで集計するので insert しておく
        insert($setProduct)

        //注文明細の作成と追加
        $order.getItemList().add(new OrderItem($setProduct))

        $item1.setDone(true)
        $item2.setDone(true)

        update($order)
        update($item1)
        update($item2)
end

rule "セット対象外"
    agenda-group "注文明細"
    no-loop
    salience 1
    when
        $order : Order()
        $item1 : OrderParameter(done == false)
    then
        //セットではない単品の商品で注文明細を作成
        $order.getItemList().add(new OrderItem($item1.getProduct()))

        $item1.setDone(true)

        update($order)
        update($item1)
end

rule "セット対象外(明細あり)"
    agenda-group "注文明細"
    no-loop
    salience 2
    when
        $order : Order()
        $item1 : OrderParameter(done == false)
        //同じ商品(単品のみ)の注文明細を取得
        $orderItem : OrderItem(product == $item1.product) from $order.getItemList()
    then
        //注文数を +1
        $orderItem.setQty($orderItem.getQty() + 1)

        $item1.setDone(true)

        update($order)
        update($item1)
end


rule "購入金額 3万5千円以上で 20% OFF"
    agenda-group "注文"
    no-loop
    salience 20
    when
        $order : Order(discountRatio == 0.0)
        $total : Number(doubleValue >= 35000) from accumulate(
            OrderItem($totalPrice : getTotalPrice()) from $order.getItemList(),
            sum($totalPrice)
        )
    then
        //関数定義した println を呼び出し
        println("*** 割引前 合計 = " + $total)
        $order.setDiscountRatio(0.2)
        update($order)
end

rule "セット商品が 5セット以上ある場合に購入金額を 10% OFF"
    agenda-group "注文"
    no-loop
    salience 10
    when
        $order : Order(discountRatio == 0.0)
        $num : Number(doubleValue >= 5) from accumulate($p : SetProduct(), count($p))
    then
        println("*** セット数 = " + $num)
        $order.setDiscountRatio(0.1)
        update($order)
end

処理スクリプト

処理の実装は Groovy で行いました。

アジェンダグループは後で setFocus() したものが先に処理されるようなのでご注意ください。

setdiscount_sample.groovy
@Grab("org.drools:drools-core:5.4.0.Beta1")
@Grab("org.drools:drools-compiler:5.4.0.Beta1")
@Grab("com.sun.xml.bind:jaxb-xjc:2.2.5-b09")
import org.drools.KnowledgeBaseFactory
import org.drools.builder.KnowledgeBuilderFactory
import org.drools.builder.ResourceType
import org.drools.io.ResourceFactory

//商品
class Product {
    String category
    String name
    int price
}
//セット商品
class SetProduct extends Product {
    List<Product> productList = []

    SetProduct(String category, String name, int price, List<Product> products) {
        this.category = category
        this.name = name
        this.price = price

        products.each {
            productList.add(it)
        }
    }

    String getName() {
        super.getName() + "(" +productList.collect{it.name}.join(", ") + ")"
    }
}
//注文
class Order {
    String orderNo
    List<OrderItem> itemList = []
    double discountRatio = 0.0

    int getTotalPrice() {
        (1.0 - discountRatio) * itemList.inject(0) {acc, item -> acc + item.totalPrice}
    }
}
//注文明細
class OrderItem {
    Product product
    int qty = 1

    OrderItem(Product product) {
        this.product = product
    }

    int getTotalPrice() {
        qty * product.price
    }
}
//注文パラメータ(ルールエンジンでセット商品のマッチングに使用)
class OrderParameter {
    Product product
    boolean done = false
}


//ルールエンジンのセッション作成
def createSession = {drlFilePath ->
    def builder = KnowledgeBuilderFactory.newKnowledgeBuilder()

    builder.add(ResourceFactory.newClassPathResource(drlFilePath, getClass()), ResourceType.DRL)

    if (builder.hasErrors()) {
        println builder.errors
        System.exit(1)
    }

    def base = KnowledgeBaseFactory.newKnowledgeBase()
    base.addKnowledgePackages(builder.getKnowledgePackages())

    base.newStatefulKnowledgeSession()
}

//注文データ
def inputData = [
    [product: new Product(category: "A", name: "商品1", price: 4000), qty: 1],
    [product: new Product(category: "B", name: "商品2", price: 3000), qty: 4],
    [product: new Product(category: "C", name: "商品3", price: 3500), qty: 3],
    [product: new Product(category: "A", name: "商品4", price: 4500), qty: 2],
    [product: new Product(category: "D", name: "商品5", price: 4500), qty: 3]
]

def order = new Order(orderNo: "order:001")

def session = createSession(args[0])

session.insert(order)

inputData.each {item ->
    (0..<item.qty).each {
        //商品 1点毎に OrderParameter を作成し insert
        session.insert(new OrderParameter(product: item.product))
    }
}

//アジェンダグループの指定(後で setFocus したものが先に処理される)
session.agenda.getAgendaGroup("注文").setFocus()
session.agenda.getAgendaGroup("注文明細").setFocus()

//ルールの適用実施
session.fireAllRules()
session.dispose()

println "合計金額 = ${order.totalPrice}, 割引率 = ${order.discountRatio}"

order.itemList.each {
    println "内訳 : <${it.product.category}> ${it.product.name} x ${it.qty} = ${it.totalPrice}"
}

実行結果は以下の通りです。後で insert した OrderParameter の方が先に処理されているようです。

実行結果
> groovy setdiscount_sample.groovy setdiscount.drl
*** 割引前 合計 = 39100.0
合計金額 = 31280, 割引率 = 0.2
内訳 : <セット1> A 1点 + B 1点(商品4, 商品2) x 1 = 5000
内訳 : <セット1> A 1点 + B 1点(商品4, 商品2) x 1 = 5000
内訳 : <セット1> A 1点 + B 1点(商品1, 商品2) x 1 = 5000
内訳 : <セット2> B 1点 + C 1点 + 何でも 1点(商品2, 商品3, 商品5) x 1 = 8800
内訳 : <セット3> D以外 2点(商品3, 商品3) x 1 = 6300
内訳 : <D> 商品5 x 2 = 9000

なお、"購入金額 3万5千円以上で 20% OFF" ルールの salience を 10 より小さく設定すると (setdiscount2.drl) 以下のような結果になります。

実行結果2
> groovy setdiscount_sample.groovy setdiscount2.drl
*** セット数 = 5
合計金額 = 35190, 割引率 = 0.1
内訳 : <セット1> A 1点 + B 1点(商品4, 商品2) x 1 = 5000
内訳 : <セット1> A 1点 + B 1点(商品4, 商品2) x 1 = 5000
内訳 : <セット1> A 1点 + B 1点(商品1, 商品2) x 1 = 5000
内訳 : <セット2> B 1点 + C 1点 + 何でも 1点(商品2, 商品3, 商品5) x 1 = 8800
内訳 : <セット3> D以外 2点(商品3, 商品3) x 1 = 6300
内訳 : <D> 商品5 x 2 = 9000