Skip to content

ECカートのクーポン・割引最適適用 (Indicator MIP) ​

問題クラスソルバー主な APIリンク
混合整数計画問題 (MIP)HiGHS (標準)m.binary(), m.constraint(), m.indicator(), c, minimize

ECサイトのカート・決済画面では、「〇〇円以上で使えるクーポン」「特定カテゴリ限定割引」「他クーポンとの併用不可(排他条件)」「最大2枚まで」といった複雑なプロモーションルールが絡み合います。

WaSHI を使えば、サーバーに負荷をかけずブラウザ内(クライアントサイド)で瞬時に支払総額が最小になるクーポンの組み合わせを計算できます。さらに、**「あと10ドルの小物を買い足せば250ドル以上の大型クーポンが解放され、結果的に支払額が5ドル安くなる」**といったスマートなレコメンドも単一の MIP モデルで同時に導出できます。

INTERACTIVE EXAMPLE · HiGHS Indicator Constraints

ECカート・クーポン&「あと少し買い」最適化

カートの商品を選んで「▶ 最適化を実行」を押すと、併用上限(最大2枚)と最低利用額(m.addIndicator)から最安になるクーポンの組と追加提案アイテムを計算します。

ワイヤレスヘッドホン$160
メカニカルキーボード$80
4K ウェブカメラ$95
アルミPCスタンド$45
モニターライトバー$55
フェルトデスクマット$25
候補: USB-Cケーブル (+$10) / クリーナー (+$15) / キーキャッププラー (+$25)
WELCOME15$100以上で $15 OFF (併用可)
未適用
MEMBER10$180以上で $10 OFF (併用可)
未適用
SAVE30_200$200以上で $30 OFF (Tier排他)
未適用
MEGA50_250$250以上で $50 OFF (Tier排他)
未適用
選択商品の小計$240
クーポン割引合計-$0
最終お支払い額 (Cash Payable)$240

シナリオ設定 ​

現在のカート小計は $242(うち Tech カテゴリ商品が $210):

  • 追加提案アイテム: $10 の USB-C ケーブルを追加すると、カート総額が $242 -> $252 に到達します。
  • 保有クーポン一覧(最大2枚まで適用可、「併用不可」クーポンは最大1枚まで):
クーポンコード割引額適用条件併用ルール
WELCOME15-$15カート総額 ≥$100\ge \$100併用可 (Stackable)
TECH30-$30Tech カテゴリ ≥$200\ge \$200併用不可 (Exclusive)
MEGA45_250-$45カート総額 ≥$250\ge \$250併用不可 (Exclusive)
MEMBER10-$10カート総額 ≥$150\ge \$150併用可 (Stackable)

WaSHI による実装 (indicator) ​

「クーポン cc を適用する(useCoupon[c] == 1)ならば、購入金額が最低条件以上である」という条件付き制約を m.indicator() で直感的に記述します。

typescript
import { Model, sum, c, minimize } from "@ohtaman/washi";

async function optimizeCheckoutCart() {
  const m = new Model("CartCouponOptimizer");

  const baseCartSubtotal = 242;
  const baseTechSubtotal = 210;

  // $10 の USB-C ケーブルを追加するかどうかの 0-1 変数
  const addUsbCable = m.binary("addon_USB_Cable");
  const usbCablePrice = 10;

  const coupons = [
    { id: "WELCOME15",  discount: 15, minCart: 100, minTech: 0,   stackable: true  },
    { id: "TECH30",     discount: 30, minCart: 0,   minTech: 200, stackable: false },
    { id: "MEGA45_250", discount: 45, minCart: 250, minTech: 0,   stackable: false },
    { id: "MEMBER10",   discount: 10, minCart: 150, minTech: 0,   stackable: true  },
  ];

  const useCoupon = m.binary(coupons.map((c) => c.id));

  const merchSpend = addUsbCable.mul(usbCablePrice).add(baseCartSubtotal);
  const techSpend  = addUsbCable.mul(usbCablePrice).add(baseTechSubtotal);

  // 1. Indicator 制約: クーポンを使う (activeValue: 1) なら最低購入金額を満たす必要がある
  for (const cpn of coupons) {
    if (cpn.minCart > 0) {
      m.indicator(useCoupon[cpn.id], merchSpend.ge(cpn.minCart), {
        activeValue: 1,
        M: 500,
        name: `min_spend_${cpn.id}`,
      });
    }
    if (cpn.minTech > 0) {
      m.indicator(useCoupon[cpn.id], techSpend.ge(cpn.minTech), {
        activeValue: 1,
        M: 500,
        name: `min_tech_${cpn.id}`,
      });
    }
  }

  // 2. 併用不可(Exclusive)クーポンは最大 1 枚まで
  const exclusiveCoupons = coupons.filter((c) => !c.stackable);
  m.constraint(
    c`${sum(exclusiveCoupons, (c) => useCoupon[c.id])} <= 1`,
    "at_most_one_exclusive"
  );

  // 3. 1回の決済で使えるクーポンは合計 2 枚まで
  m.constraint(
    c`${sum(coupons, (c) => useCoupon[c.id])} <= 2`,
    "max_two_coupons"
  );

  // 目的関数: 最終支払額 (商品総額 - 割引合計) を最小化
  const totalDiscount = sum(coupons, (c) => useCoupon[c.id].mul(c.discount));
  m.objective(minimize(merchSpend.sub(totalDiscount)));

  const status = await m.solve();
  console.log(`Status: ${status}, Optimal Net Payable: $${m.objectiveValue}`);
  console.log(`Recommended Add-on (+$10 USB-C Cable): ${(addUsbCable.value ?? 0) > 0.5 ? "YES" : "NO"}`);
  for (const cpn of coupons) {
    const applied = (useCoupon[cpn.id].value ?? 0) > 0.5;
    console.log(`  ${applied ? "✅ [APPLIED]" : "   [skipped]"} ${cpn.id.padEnd(11)} (-$${cpn.discount})`);
  }
}

optimizeCheckoutCart();
Playground で開く →

Released under the MIT License.