E-commerce 中促銷系統的設計

Max發表於2018-12-26

在電商平臺中,促銷是必不可少的營銷手段,尤其在國內 各種玩法層出不窮,最開始的滿減/秒殺 到優惠卷 再到 拼團/砍價等等

一個良好的促銷系統應該具備易於擴充套件,易於統計促銷效果等特點,在遇到秒殺類促銷時還需要做到可擴容,抗併發(本次不考慮秒殺系統的設計)等等. 廢話說完了,進入正題吧

概覽

對各種促銷行為進行分析,會發現本質上是由兩個部分和一個作用域組成.

促銷的核心作用域既訂單.因此我在上一篇文章中介紹了電商中訂單系統的設計 E-commerce 中訂單系統的設計

兩個部分既上圖中的rule和action部分.

rule描述了促銷限制,既訂單需要滿足那些條件才能參與某個促銷.常見的促銷限制有 訂單金額/購買時間/購買數量/收貨地址/支付方式/使用者型別/購買人數 等等.

action描述了給予訂單哪些優惠策略 如折扣/直減/免運費/返現/贈品 等等.

這樣設計最大好處是 rule與action相互獨立且高度抽象, 運營人員與開發人員可以自由組合rule和action來達到最大靈活性與可擴充套件性

資料庫設計

Promotion

Schema::create('promotions', function (Blueprint $table) {
    $table->increments('id');
    $table->string('code');

    $table->string('name')->nullable();
    $table->string('description')->nullable();
    $table->string('cover')->nullable()->comment('促銷封面');
    $table->string('asset_url')->nullable()->comment('促銷詳情連結')

    $table->integer('position')->default(0)->comment('權重');
    $table->string('type')->comment('優惠卷/滿減促銷/品牌促銷/秒殺/拼團/通用.');

    $table->json('config')->nullable()->comment('配置');

    $table->timestamp('began_at')->nullable()->comment('促銷開始時間');
    $table->timestamp('ended_at')->nullable()->comment('促銷結束時間');
    $table->timestamps();
    $table->softDeletes();
});

為了實現良好的促銷效果統計行為,所有的促銷行為都應該對應promotion表中的一條記錄.

Rule

Schema::create('promotion_rules', function (Blueprint $table) {
    $table->increments('id');
    $table->unsignedInteger('promotion_id');
    $table->string('type');
    $table->json('config')->nullable();
    $table->timestamps();

    $table->index('promotion_id');
});

常見的rule type有

  • 訂單總額 order_total
  • 訂單中促銷專案總額 promotion_items_total
  • 第N筆訂單 nth_order
  • 所屬分類 has_category
  • 消費者使用者組 customer_group (白金會員組/鑽石會員組 等等)
  • 購買數量 item_quantity
  • 等等

Action

 Schema::create('promotion_actions', function (Blueprint $table) {
     $table->increments('id');
     $table->unsignedInteger('promotion_id');
     $table->string('type');
     $table->json('config')->nullable();
     $table->timestamps();

     $table->index('promotion_id');
 });

常見的action type有

  • 訂單固定折扣 order_fixed_discount
  • 訂單百分比折扣 order_percentage_discount
  • 訂單中促銷專案固定折扣 promotion_items_fixed_discount
  • 訂單中促銷專案階梯式折扣 promotion_items_ladder_discount
  • 贈送積分 present_integral
  • 運費百分比折扣 shipping_percentage_discount
  • 等等

json型別的config欄位的靈活應用是促銷系統靈活的另一個主要原因

關於json欄位的使用細項,及索引方式 可以參考 MySQL 中 JSON 欄位的使用技巧

PromotionVariant

在常見的電商平臺中,一個促銷活動通常不會涉及所有的商品, 尤其是類似淘寶這種B2C模式的平臺,促銷通常是以商家報名的形式展開的. 因此我們會有一個表來記錄 有哪些變體(variant)參與了本次促銷.

變體(variant)即sku, 下文將統稱為變體.

另外不以product作為參與促銷的最小單位, 是為了進行更細顆粒度的控制.

一個促銷可以有多個變體參與,一個變體可以同時參與多個促銷. 因此 promotion_variants 實際上是promotions表和variants表中間的一張中間表, 並且這張中間表攜帶了其他資訊, 來看看遷移檔案

Schema::create('promotion_variants', function (Blueprint $table) {
    $table->increments('id');

    $table->unsignedInteger('variant_id')->index();
    $table->unsignedInteger('promotion_id')->index();

    $table->decimal('discount_rate')->nullable()->comment('折扣率, 值為0.3表示打7折');
    $table->unsignedInteger('stock')->nullable()->comment('促銷庫存');
    $table->unsignedInteger('sold')->default(0)->comment('銷售數量');
    $table->unsignedInteger('quantity_limit')->nullable()->comment('購買數量限制');
    $table->boolean('enabled')->default(1)->comment('啟用');

    // 冗餘
    $table->unsignedInteger('product_id');
    $table->string('promotion_type')->comment('冗餘promotion表type');
    $table->json('rest')->nullable()->comment('冗餘');

    $table->timestamps();
});

上面便是促銷系統的核心表,資料庫欄位可以按照實際需求進行增減和修改,特殊促銷可自行新增相關表, 如優惠卷促銷的coupons表, 拼團的groups表, 報名促銷的promotion_sign_up表等等

業務設計

流程設計

以一次聖誕節滿減促銷為例,第一步的工作是建立promotion和相應的rules和actions. 我們首先會有這樣3條記錄

// promotion
{
    id: 1,
    code: '2018-christmas',
    name: '聖誕節滿減大促',
    type: 'full_discount',
    description: '促銷商品滿100減10元',
    cover: null,
    asset_url: null,
    rest: null,
    config: null,
    position: 0,
    began_at: '2018-12-25 00:00:00',
    ended_at: '2018-12-26 00:00:00'
}

// rule
{
    'id': 1,
    'promotion_id': 1,
    'type': 'promotion_items_total', // 訂單中促銷項總額
    'config': {
        'amount' => 10000, // unit/分 
    }
}

// action
{
    'id': 1,
    'promotion_id': 1,
    'type': 'promotion_items_fixed_discount', // 訂單中促銷項 固定折扣
    'config': {
        'amount' => 1000, // unit/分 
    }
}

當促銷建立完成後,下一步就是確定本次促銷的變體了.

對於自營網站,由網站運營建立促銷,挑選變體並新增到promotion_variants表中.對於B2C平臺,由網站運營建立促銷,商家選擇變體並報名參與本次促銷,運營稽核後將其新增到相應的promotion_variants表中.

當促銷的變體確定後. 對於有需要的促銷,可以為促銷設計聚合頁面/詳情頁/宣傳頁/推廣頁,然後將相應的連結和封面新增到promotion.asset_url和promotion.cover中儲存即可.

程式碼邏輯

訂單對促銷的判斷的邏輯的laravel虛擬碼

// 獲取平臺所有有效的促銷
$promotions = Promotion::active()->get();

// 通過rule過濾promotion
$promotions = $promotions->filter(function ($promotion) {
    $rules = $promotion->rules
    $order = $this->getOrder();

    // 判定訂單是否滿足所有rule,當存在一條rule不被訂單所滿足,應返回false,被過濾器過濾掉

    return true;
});

// 為訂單應用action.
$promotion->each(function ($promotion) {
    $actions = $promotion->actions;
    $order = $this->getOrder();

    // 將actions逐條應用於訂單
})

特別注意: 對訂單應用actions並不意味著直接修改訂單中的商品單價或支付總額等. 而應有條理的記錄影響訂單支付金額的行為和原因. 既使用上一篇中提到的adjustment來記錄 E-commerce 中訂單系統的設計

關於action和rule的程式碼邏輯可以先來看兩個interface

<?php

namespace Promotion\Constructs;

interface Checker
{
    public function isEligible(array $configuration): bool;
}
<?php

namespace Promotion\Constructs;

interface Action
{
    public function execute(array $configuration);
}

每一條rule的設計都要實現上面的 Checker介面,每一條action都要實現上面的Action介面.

以上面的聖誕滿減促銷的rule和action為例子,來看看具體的實現

<?php

namespace Promotion\Checker;

/**
 * 有很多的通用方法 如getOrder,getPromotionOrderItems等. 
 * 因此我建立了一個基類checker來實現interface和通用方法
 */
class PromotionItemsTotalChecker extends Checker
{
    public function isEligible(array $configuration): bool
    {
        return $this->getPromotionOrderItemsTotal() >= $configuration['amount'];
    }
}

需要注意一點,一筆訂單中可能存在許多變體,但通常情況是隻有部分變體參加了聖誕大促.因此我們計算購物總額時應該使用order中參與了聖誕促銷items

<?php

namespace Promotion\Actions;

use Promotion\Helpers\CreateAdjustment;
use Promotion\Helpers\Distribute;

class PromotionItemsFixedDiscountAction extends Action
{
    use Distribute, CreateAdjustment;

    public function execute(array $configuration)
    {
        // 滿減的金額
        $amount = $configuration['amount'];

        if ($amount === 0) {
            return false;
        }

        // 格式校驗, amount如果小於訂單金額時,則使用訂單金額作為優惠amount
        $amount = -1 * min($this->getPromotionOrderItemsTotal(), $amount);

        if ($amount === 0) {
            return false;
        }

        $items = $this->getPromotionOrderItems();

        $itemsTotals = [];

        foreach ($items as $item) {
            $itemsTotals[] = $item->total;
        }

        // 促銷金額等比例分配.
        $splitAmount = $this->distributeAmountOfItem($itemsTotals, $reduceAmount);

        // 建立adjustments
        $this->createUnitsAdjustment($items, $this->getPromotion(), $splitAmount);

    }
}

本文的主要目的是提供思路與想法, 因此沒有太過具體完整的程式碼.

未來如果有機會的話會設計一些促銷系統擴充套件等提供參考.

上面便是一個促銷系統的流程思路,下面多提供一些demo供參考

優惠卷

已一張10元代金卷為例,我們會有這樣兩條記錄

// promotion
{
    id: 1,
    code: '10-cash',
    name: '10元代金券',
    type: 'coupon',
    description: '全場可用',
    cover: null,
    asset_url: null,
    config: {
        type: 'cash',
        reduce_amount: 1000, // 冗餘自下面action中的config中的amount
        stock: 10000, // 庫存數量
        sold: 0, // 已經領取的數量
        catch_limit: 1, // 領取限制
        date_type: 'fix_term', // 固定期限
        fix_term: 30, // 自領取日內30天有效,

        // date_type: 'fix_time_range', 固定時間段
        // began_at: '2018-12-23 00:00:00',
        // ended_at: '2018-12-25 00:00:00',
    },
    position: 0,
    began_at: '2018-12-25 00:00:00',
    ended_at: '2018-12-26 00:00:00'
}

// action
{
    'id': 1,
    'promotion_id': 1,
    'type': 'order_fixed_discount', // 訂單中促銷項 固定折扣
    'config':{
       'amount' => 1000, // unit/分  
    }
}

代金券通常沒有使用限制,因此不需要rule.

代金券通常是全場可用, 因此action我們使用 order_fixed_discount,而不是promotion_items_fixed_discount.

對於config中的配置適用於各種優惠卷,如滿減卷,運費卷等等.

對於滿減卷的配置只要再為這筆促銷新增一個型別為promotion_items_total (部分變體滿減)或者order_total(全場滿減) 的rule即可

優惠卷促銷通常要建立一個 coupons表來儲存使用者領取的優惠卷及使用情況等

優惠卷促銷本質上是將傳統促銷以卷的形式體現了出來,既聖誕滿減促銷 => 聖誕滿減卷的轉換.

秒殺/直減/聚划算

直減型別促銷通常是已變體為單位進行高折扣的促銷行為,秒殺具體要折扣多少通常不是統一設定的,不同的變體會有不同的折扣率,所以可能會有這樣兩條記錄

// promotion
{
    id: 1,
    code: 'unit-discount-1290',
    name: '1290期直減',
    type: 'unit_discount',
    description: null,
    cover: null,
    asset_url: null,
    config: null,
    position: 0,
    began_at: '2018-12-25 00:00:00',
    ended_at: '2018-12-26 00:00:00'
}

// promotion_variant 
{
    'id': 1,
    'variant_id': 1,
    'prootion_id': 1,
    'discount_rate': 0.35,
    'stock': 100, // 秒殺庫存
    'sold': 0,
    'quantity_limit': 1, // 限購
    'enabled': 1,
    'product_id': 1,
    'promotion_type': 'unit_discount',
    'rest': {
        variant_name: 'xxx', // 秒殺期間變體名稱
        image: 'xxx', // 秒殺期間變體圖片
    }
}

promotion_variant 由運營新增或者供應商報名得到.直減並沒有相應的rule/action組合而來, 屬於特殊促銷.

但是在程式碼邏輯中依舊可以提現出這種特殊的rule和action

UnitDiscountChecker來判定訂單是否可以參與本次秒殺促銷,

通過UnitDicountAction來記錄相應的PromotionOrderItems的折扣資訊,既下面的虛擬碼

// rule驗證階段
if ($promotion->type === 'unit_discount') {
   return (new UnitDiscountChecker)->isEligible()
}

// 應用action階段
if ($promotion->type === 'unit_discount') {
    (new UnitDiscountAction)->execute()
}

階梯式滿減

階梯式滿減屬於傳統滿減促銷的一個變種.下面是一個 滿100 - 10,滿150 - 20,滿200 - 30的階梯式滿減的action記錄.

// action
{
    'id': 1,
    'promotion_id': 1,
    'type': 'promotion_items_ladder_discount',
    'config': {
        "ladder": [
            {
                "least_amount": 10000,
                "reduce_amount": 1000
            }, {
                "least_amount": 15000, 
                "reduce_amount": 2000
            }, {
                "least_amount": 20000, 
                "reduce_amount": 3000
            }
        ]
    }
}

具體的ladder應該由運營人員後臺設定,實際上對於每一種action和rule的type,在後臺管理介面中都應該設定其相應的表單互動

如果你有疑惑或者更多的想法歡迎留言.

相關文章