E-commerce 中訂單系統的設計

Max發表於2018-12-22

資料庫設計

Order

訂單系統的核心表自然是 orders系列表,laravel的遷移檔案如下

Schema::create('orders', function (Blueprint $table) {
    $table->increments('id');
    $table->string('number')->nullable()->comment('訂單號');
    $table->unsignedInteger('address_id')->nullable()->comment('訂單地址');
    $table->unsignedInteger('user_id')->index()->comment('使用者id');
    $table->integer('items_total')->default(0)->comment('order每一個item的total的和 unit/分');
    $table->integer('adjustments_total')->default(0)->comment('調整金額 unit/分');
    $table->integer('total')->default(0)->comment('需支付金額 unit/分');

    $table->string('local_code')->comment('語言編號');
    $table->string('currency_code')->comment('貨幣編號');

    $table->string('state')->comment('主狀態 checkout/new/cancelled/fulfilled');
    $table->string('payment_state')->comment('支付狀態 checkout/awaiting_payment/partially_paid/cancelled/paid/partially_refunded/refunded');
    $table->string('shipment_state')->comment('運輸狀態 checkout/ready/cancelled/partially_shipped/shipped');

    $table->ipAddress('user_ip')->comment('使用者ip ip2long後的結果');

    $table->timestamp('paid_at')->nullable()->comment('支付時間');
    $table->timestamp('confirmed_at')->nullable()->comment('確認訂單時間');
    $table->timestamp('reviewed_at')->nullable()->comment('評論時間');
    $table->timestamp('fulfilled_at')->nullable()->comment('訂單完成時間');

    $table->json('rest')->nullable()->comment('非核心欄位冗餘');

    $table->timestamps();
});

接下來是order_items表,用於記錄order的item

Schema::create('order_items', function (Blueprint $table) {
    $table->increments('id');
    $table->unsignedInteger('order_id')->index()->comment('外來鍵');
    $table->unsignedInteger('variant_id')->comment('variant是國外的稱呼,國內通常稱為sku. 既庫存最小單位');
    $table->unsignedInteger('product_id')->comment('冗餘欄位');
    $table->unsignedInteger('quantity')->comment('購買數量');

    // adjustment calculate
    $table->integer('units_total')->default(0)->comment('item中每一個unit的和. 單位/分');
    $table->integer('adjustments_total')->default(0);
    $table->integer('total')->default(0)->comment('units_total + adjustments_total');
    $table->integer('unit_price')->default(0)->comment('variant單價,冗餘欄位');

    $table->json('rest')->nullable()->comment('非核心欄位冗餘');
    $table->timestamps();
});

做過海外電商或者亞馬遜的朋友應該對variant(變體)不陌生. 國內稱為sku. 每一個商品都會有多個變體

接下來是order_item_units

Schema::create('order_item_units', function (Blueprint $table) {
    $table->increments('id');
    $table->unsignedInteger('item_id')->index();
    $table->unsignedInteger('shipment_id')->comment();
    $table->integer('adjustments_total')->default(0);
    $table->timestamps();
});

對於使用者購買的每一件實體,我們都需要謹慎的做一條記錄,其會涉及到運輸/促銷/退貨等問題, 例如variantA我們購買了三件,那麼我們就需要為這三件相同的變體分別建立三條記錄.

上面三張表的關係從上往下 一個order會有多個item,一個item根據quantity的值,會有對應數量的unit.

order和order_item表大家應該都知道.

order_item_units表可能有些同學第一次知道,但是其是必要存在的

tip: 所有的價格欄位都使用分為單位儲存,從而避免小數在計算機系統中存在的一些問題

可以消化梳理一下上面的三張訂單系統核心表,然後再介紹一下其他相關表的設計. 資料庫的設計應該是靈活的,可以根據實際的需求任意新增和修改欄位

Adjustment

上面三張表都出現了adjustment_total欄位,可能會有些疑惑.

如果我們每個變體的價格是10元,那我買三個這件變體則需要30元,但是實際支付的金額往往都不是30元.,會有各種各樣的情況影響我們最終支付的價格.

比如運費+5元,促銷折扣 -8元,稅收+3元,退還服務 +0.5元,最後實際需要支付 35.5元. 為什麼30元的金額最後卻支付了35.5元?

我們不能憑空蹦出個35.5元,影響商品實際支付金額的每一個因素都是至關重要,我們需要負責任的記錄下來.這便是adjustment表的來源.

首先看看遷移檔案

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

    $table->unsignedInteger('order_id')->nullable();
    $table->unsignedInteger('order_item_id')->nullable();
    $table->unsignedInteger('order_item_unit_id')->nullable();

    $table->string('type')->comment('調整的型別 shipping/promotion/tax等等');

    $table->string('label')->comment('結合type決定');

    $table->string('origin_code')->comment('結合label決定');

    $table->bool('included')->comment('是否會影響最終訂單需要支付的價格')
        $table->integer('amount');
    $table->timestamps();

    $table->index('order_id');
    $table->index('order_item_id');
    $table->index('order_item_unit_id');
});

調整對訂單價格的影響分為三種型別, 分別是 影響整個order, 影響order_item(較少預見),影響order_item_units.

included欄位 用來判斷本條adjustment記錄,是否會影響消費者最終需要支付的金額

大部分的adjustment都會影響最終結算的價格, 小部分如商品稅,通常已經計算在了商品的單價中, 不會影響消費者最終需要支付的金額.但是在開具發票時 卻需要展示,因為我們做必要的記錄

舉個例子, 假設我們一筆訂單的運費是5元,那麼會有這樣一條adjustment記錄

{
    id: 1,
    order_id: 1,
    order_item_id: null,
    order_item_unit_id: null,
    amount: 500,
    type: 'shipping',
    label: 'UPS',
    origin_code: null,
    included: 1,
}

假設我們消費者在一個訂單中購買了三條1.5米資料線,並使用了一張8元的代金券,那麼會有這樣三條adjustment記錄

[
    {
        id: 2,
        order_id: null,
        order_item_id: null,
        order_item_unit_id: 1,
        amount: -267,
        type: 'promotion',
        label: '8元代金券',
        origin_code: 'KSDI12K2', // 代金券code
        included: 1
    },
    {
        id: 2,
        order_id: null,
        order_item_id: null,
        order_item_unit_id: 2,
        amount: -267,
        type: 'promotion',
        label: '8元代金券',
        origin_code: 'KSDI12K2', // 代金券code
        included: 1
    },
    {
        id: 2,
        order_id: null,
        order_item_id: null,
        order_item_unit_id: 3,
        amount: -266,
        type: 'promotion',
        label: '8元代金券',
        origin_code: 'KSDI12K2', // 代金券code
        included: 1
    },
]

實際上對於大部分的促銷需求 我們都應該將促銷的折扣金額均分到每一個unit中.

這樣設計的一個好處是,當消費者退呼叫其中一根資料線時,我們可以很清楚的計算出應該退多少金額給消費者. 既 單價 + order_item_unit.adjustment

實際上清楚的記錄每一筆影響最終支付金額的adjustment,無論對消費者還是對供應商來說都是負責的做法.

運費為什麼不需要分攤到unit?

運費對於一筆訂單來說,是固定的外部消費(由快遞公司獲利),退款時商家並不需要為運費負責, 只需要退還商品的等額價值即可

更加白話的說法就是 你在淘寶買了一個商品20元,運費10元, 你覺得商品不好想要退貨(不考慮寄回的運費), 商家需要退你30元嗎?

Shipment/Payment

shipment為訂單的運輸資訊儲存,payment為支付資訊儲存.先來看看遷移檔案

Schema::create('shipments', function (Blueprint $table) {
    $table->increments('id');
    $table->unsignedInteger('method_id')->comment('運輸方式 外來鍵');
    $table->unsignedInteger('order_id')->comment('訂單 外來鍵');
    $table->string('state')->comment('運輸狀態');
    $table->string('tracking_number')->nullable()->comment('訂單號碼');
    $table->timestamps();

    $table->index('order_id');
});
Schema::create('payments', function (Blueprint $table) {
    $table->increments('id');
    $table->unsignedInteger('method_id')->comment('支付方式');
    $table->unsignedInteger('order_id');7
    $table->string('currency_code', 3)->comment('冗餘 貨幣編碼');
    $table->unsignedInteger('amount')->default(0)->comment('支付金額');
    $table->string('state');
    $table->text('details')->nullable();
    $table->timestamps();

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

上面在order_item_units表中存在一個shipment_id 就對應這裡的shipment表. shipment和order_item_units之間是一對多的關係,訂單中的每一個實體都可以被分別運輸,例如京東購物時經常會見到這種情況.

一條shipment/payment 會和一條實際存在的貨運記錄/支付記錄(退款記錄) 掛鉤.

上面就是訂單系統的核心表了,對於後端來說,資料庫就已經可以反映出整個系統的設計了.

接下來抽出一些細節進行詳細的介紹

業務設計

狀態的設計

相信很多小夥伴在做訂單系統時會被各種狀態 待確認,待支付,待發貨,已發貨,關閉訂單 等等弄的暈頭轉向,今天我們就來梳理一下訂單系統中的各種狀態

如果各種狀態只在order表使用一個state欄位來記錄顯得有些力不從心,因此推薦使用三個欄位,它們分別是 state,shipment_state,payment_state. 來分別記錄在訂單中我們或者消費者最關心的三種狀態.

先來分別看看三個state的狀態轉移圖

order.state↓

這是一筆訂單的幾個最基本的幾個狀態.

先講一講初始狀態,既 checkout, 這與訂單在什麼時候建立有關係,當消費者在購物車點選結賬時,就建立了一個訂單,用於本次結賬, 因此訂單的初始狀態為checkout

結賬也就是所謂的確認訂單頁,在該頁面中,消費者可以選擇優惠券,選擇地址等操作

處於該狀態的訂單對於後臺管理系統/使用者個人中心都是不可見的,且checkout型別訂單的建立,也不會是庫存有任何的變化

當使用者在結賬介面操作完成後需要使用者點選確認訂單. 既行為 confirm的觸發,使訂單的狀態從checkout轉換成了new. 此時的訂單無論是對於消費者/運營人員/倉儲系統來說,都是真實存在且有效的. 且響應的購物車記錄也被清空. 對於一筆狀態為new的訂單,消費者可以對其行使付款的權利.

order.payment_state↓

payment的初始狀態為checkout與上述一致.

當消費者觸發confirm後, 我們就可以觸發request_payment行為,將訂單的付款狀態轉換為 await_payment, 且將消費者引導到支付介面, 當消費者支付成功後,在支付成功的回撥中,觸發pay行為,將支付狀態轉換為paid.

關於退款的狀態如上圖所示,需要注意的是,對於退款,會出現只需要退訂單中的部分商品的情況,因此加入了 partially_refunded(部分退款的狀態).

order.shipment_state↓

當消費者confirm後, 我們同時也需要呼叫響應的request_shipment,將我們的運輸狀態設定為一個ready狀態,此時庫存已經鎖定.

關於倉庫具體的備貨時機 是在使用者確認訂單之後,還是等使用者支付完成之後,需要根據實際的產品需求確定.

上面的狀態圖屬於前者,當消費者確認訂單後,便鎖定了庫存,並開始了備貨階段.如果是後一種情況可以將checkout修改為pending,等待消費者付款完成後再將狀態轉移到ready

對於上面繁雜的狀態轉換,可以手動處理,也可以選擇使用state-machine 進行處理

訂單價格的計算

單價作為一件商品的固有屬性,不會受到運輸/促銷折扣等等因素的影響. 當商家對一個價值100元商品進行一個30%的折扣時,消費者只需要用70元的價格買入, 但實際上商品的單價依舊是100元.

當一筆訂單不存在任何的adjustment時,我們可以很容易的計算出訂單的實際支付價格, 只需要把各個order_item的unit_price * quantity 相加起來即可

但是有了adjustment參與之後,我們必須自下往上的計算. 下面的例子是在laravel專案且使用了上述的資料庫設計後的一個計算方法.

public function calculator(Order $order)
{
    $items = $order->items;
    $items->load('adjustments', 'units.adjustments');
    $order->load('adjustments');

    $items->each(function ($item) {
        $item->units->each(function ($unit) {
            $unit->adjustments_total = $unit->adjustments->sum('amount');
        });

        $item->units()->saveMany($item->units);

        $item->adjustments_total = $item->adjustments->sum('amount');

        $item->units_total = $item->quantity * $item->unit_price + $item->units->sum('adjustments_total');

        $item->total = $item->units_total + $item->adjustments_total;
    });

    $order->items()->saveMany($items);

    $order->adjustments_total = $order->adjustments->sum('amount');
    $order->items_total = $order->items->sum('total');
    $order->total = $order->items_total + $order->adjustments_total;
    $order->save();
}

補充

  1. 當訂單建立的同時(結賬階段)就分別建立了一條payment/和shipment記錄.在payment和shipment中分別記錄了使用者選擇的支付方式與運輸方式.

    在電商系統中,通常會有多種多樣的支付方式和運輸方式.

    但是在實際的業務編寫時,業務層並不希望關心和處理繁雜的支付與運輸方式,此時支付閘道器和運輸閘道器便應運而生,其對業務層隱藏了繁雜的細節,而暴露出了統一的api介面.

    支付閘道器如提供商業服務的 ping++,當然也有一些開源專案對這方面有所支援. 如 yansongda/pay , Payum/Payum等等

  2. 對於確認了但超過一定時間沒有付款的訂單,我們可以選擇主動關閉該訂單. 將order.state/order.payment_state/order.shipment_state 設定為cancelled,並對庫存進行歸還等系列操作

下一篇將會介紹促銷系統的設計與實現,本篇的主要目的是介紹訂單系統的相關設計,為下一篇做一個鋪墊.

由於篇幅有限並沒有過多的細節,有疑問或者不妥的地方歡迎留言.

相關文章