實作練習 : 員工午餐訂購系統實作,使用stored procedure/function、trigger


員工午餐訂購系統是一個常見的小系統,應該包含[訂餐系統]、[訂單管理系統]、[員工部門管理系統]、[合作店家與菜單管理系統],讓管理者可以維護員工、部門、合作店家與菜單,讓員工每天可以選擇餐廳與餐點,在中午前彙整所有訂單,每筆訂單要記錄訂購人、餐點、數量、價格與狀態,在月底可以統計每位員工的餐費,作為薪資扣款依據。

系統規模雖然小,但是已經具備線上電商的雛形。

這篇文章將使用 stored procedure、stored function、以及 trigger 來實作[訂餐系統]的程序,這是員工午餐訂購系統的核心作業。

[訂餐系統]需要考量以下幾個需求 :

(1) 員工訂餐後,合作店家必須能夠供應,這就牽涉到餐點庫存管理。
(2) 餐點並非無限量供應,這就牽涉到訂單確認的先後順序。
(3) 一筆訂單有多項餐點時,又有多種處理方式 ~ 部分餐點庫存不足時,是否先確認有庫存的餐點? 還是整筆訂單取消? 還是由員工選擇?
(4) 合作店家供餐時,是否允許任意分批出餐? 還是必須同筆項目一起出餐? 

當然你可以很粗造的把需求簡化 : 無限量供餐、以一筆訂單出餐方式。這樣的話,系統就非常好寫,但是在實際使用時就會發生很多問題。

會發生什麼問題 ?

(A) 員工訂餐完成後,過了半小時告知 : 你訂的滷肉飯沒了,請另外再訂。
(B) 餐點送到後,負責人員發現無法核銷,因為訂了兩碗滷肉飯、燙青菜、貢丸湯只有送來一碗滷肉飯跟燙青菜,必須另外用紙寫起來 : 某訂單編號的一碗滷肉飯、貢丸湯尚未送到。
(C) 上述已送到的一碗滷肉飯該不該給員工領餐? 還是等該訂單全部到了再一次領餐? 結果系統無法處理這麼瑣碎的細節。

這麼多細節看起來,前面說員工午餐訂購系統是一個常見的小系統,其實還真不小。

所以這篇文章只先討論員工午餐訂購系統的核心作業~[訂餐系統]。

並且因為 InfinityFree 平台不提供給免費空間使用 stored procedure、stored function、以及 trigger ,因此這個實作只能在 XAMPP 本機上操作。

我們會使用 stored procedure、stored function、以及 trigger 來實作,因此先來看看這些是什麼?


什麼是觸發器 (Trigger) ?

觸發器 (Trigger) : 是一種自動執行的程式,它會在表格上執行指定的INSERT、UPDATE或DELETE操作時被觸發。觸發器可以用來自動執行一些維護數據一致性的任務,例如驗證數據、記錄變更日誌等。

關於 Trigger ,需要知道以下幾個重點 :
(1) 觸發器作用的資料表,不要於觸發器內對該資料表再使用UPDATE/INSERT/DELETE。
(2) 觸發器只能在那個特定的表上生效,無法跨表生效。
(3) 每個表格的同一操作只能有一個BEFORE或一個AFTER觸發器。
(4) 觸發器不能再觸發另一個觸發器。
(5) 觸發器中不能執行動態SQL(即使用PREPARE和EXECUTE語句)。
(6) 要避免在觸發器中執行耗時的操作,以免影響系統性能。
(7) InnoDB支援Transaction及行級鎖(Row-level Lock),比其他儲存引擎能夠維持資料一致性。
(8) 觸發器執行時不會返回結果,只能執行 SQL 邏輯。

上面說觸發器中不能執行動態SQL,意思是 Trigger 裡要寫「固定 SQL」,不要用字串組 SQL。真的需要動態 SQL,應該放在 Stored Procedure(儲存程序)裡執行,而不是放在 Trigger 裡。因為 Trigger 的原則是不要有不可預期的處理,或執行耗時的操作。

詳細資訊 : 實作練習:MySQL Trigger 觸發器是什麼?如何使用?


什麼是Stored Procedure (預儲程序)? Stored Function (預儲函數)

Stored Procedure (預儲程序):把一連串的SQL程序步驟儲存起來,最後透過 『call 預儲程序名稱;』來呼叫。 

Stored Function (預儲函數):或稱為使用者定義函數,跟預儲程序很像,不過最後會傳回值。呼叫方式不是 call,而是Select,例如:『select 預儲函數名稱(引數值);』或『select 預儲函數名稱();』。

在使用 Trigger、Stored Procedure、Stored Function 時,有些注意事項 :
(1)儲存程序(Stored Procedure):
--可以呼叫其他的儲存程序。
--可以呼叫儲存函數。
--可以在觸發器內被呼叫 (註1)。

(2)儲存函數(Stored Function):
--可以呼叫其他的儲存函數。
--不要再呼叫儲存程序 (註2)
--可以在觸發器內被呼叫。

(3)觸發器(Trigger):
--可以呼叫儲存程序 (註1)。
--可以呼叫儲存函數。
--不能包含或呼叫其他觸發器
--不能使用Commit或是Rollback

特別說明 :
註1 : Trigger 可以用簡單的 CALL 呼叫 Stored Procedure,但不能呼叫「會回傳資料給 client」或「使用 Dynamic SQL」的 Stored Procedure;Stored Procedure 可以透過 OUTINOUT 參數回傳資料給 Trigger。
註2 : Stored Function(儲存函式)不是絕對不能呼叫 Stored Procedure,而是被呼叫的 Procedure 不能做某些事,例如不能回傳結果集、不能做交易控制等。

Stored Procedure 負責流程。
Stored Function 負責回傳值。
Trigger 負責簡單防呆。
不要把 Trigger 寫成主要流程控制中心,也不要把 Function 寫成隱藏版 Procedure。

簡單來說,Trigger(觸發器)主要是設計來做簡單、明確、可預期的資料檢查或自動處理。在實務上,Trigger 裡面應該盡量避免再呼叫複雜的 Stored Procedure(儲存程序)或 Stored Function(儲存函式),也要避免透過資料異動造成其他 Trigger 連鎖觸發,否則系統行為會變得不容易追蹤,除錯也會變困難。

同樣地,Stored Function(儲存函式)的主要目的,是根據輸入參數計算並回傳一個值。因此在實務設計上,Function 應該保持單純,盡量不要再呼叫 Stored Procedure,尤其不要把複雜商業流程、交易控制或大量資料異動放進 Function 裡。

詳細資訊 : 
實作 : 練習使用Stored Procedure以及Stored Function
總整理與實作練習 : MySQL Trigger/Stored Function/Stored Procedure


使用 stored procedure/function、trigger 常見錯誤 :

1. 把所有邏輯都寫在 Trigger 裡
Trigger 應該用於自動補充與紀錄,不適合承擔主要業務流程,主要流程應該放在 Stored Procedure。 

2. 訂單明細沒有保留價格快照 如果 order_items 只記錄 dish_id,日後餐點漲價,舊訂單金額可能會被錯誤計算,所以 order_items 應該保留: dish_name_snapshot unit_price_snapshot subtotal 

3. 直接刪除訂單 訂單資料通常不建議直接刪除,應該改用狀態欄位,例如: pending confirmed cancelled served 這樣才能保留完整歷史紀錄。 

4. Stored Function 做太多資料異動 Stored Function 適合計算與回傳結果,不適合拿來大量更新資料,大量流程操作應該交給 Stored Procedure。

舉幾個例子來說明 : 

CREATE TABLE mystock (
stock_id INT AUTO_INCREMENT PRIMARY KEY,
item_id VARCHAR(10) NOT NULL,
myqty INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

DELIMITER $$

CREATE TRIGGER trg_mystock
BEFORE INSERT ON mystock 
FOR EACH ROW
BEGIN
IF NEW.myqty < 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '庫存資料不合法:數量不可小於 0';
END IF;
END$$

DELIMITER ;

當我插入資料到 MyStock 時,如果插入負值,就會出現錯誤訊息如下圖 : 


如果要從 MyStock 抓出庫存資料,可以建立一個 stored function 如下 : 

DELIMITER $$

CREATE FUNCTION fn_get_myqty(
p_item_id VARCHAR(10)
)
RETURNS INT
READS SQL DATA
BEGIN
DECLARE v_myqty INT DEFAULT 0;

DECLARE CONTINUE HANDLER FOR NOT FOUND
SET v_myqty = 0;

SELECT myqty
INTO v_myqty
FROM mystock
WHERE item_id = p_item_id;

RETURN IFNULL(v_myqty, 0);
END$$

DELIMITER ;

當我們去抓庫存資料表時,SELECT fn_get-mystock ('1000'); 就可以抓到庫存資料,如下圖 :


要處理訂購,可以寫個 stored procedure 去處理 mystock,如下 :

DELIMITER $$

DROP PROCEDURE IF EXISTS sp_order_mystock$$

CREATE PROCEDURE sp_order_mystock(

    IN p_item_id VARCHAR(10),

    IN p_order_qty INT

)

BEGIN

    DECLARE v_stock_id INT DEFAULT 0;

    DECLARE v_myqty INT DEFAULT 0;

    DECLARE v_not_found TINYINT DEFAULT 0;

    DECLARE CONTINUE HANDLER FOR NOT FOUND

        SET v_not_found = 1;


    DECLARE EXIT HANDLER FOR SQLEXCEPTION

    BEGIN

        ROLLBACK;

        SELECT 

            0 AS success,

            '系統錯誤,交易已取消' AS message;

    END;


    IF p_order_qty IS NULL OR p_order_qty <= 0 THEN

        SELECT 

            0 AS success,

            '訂購數量必須大於 0' AS message,

            p_item_id AS item_id,

            p_order_qty AS order_qty,

            NULL AS remaining_qty;

    ELSE

        START TRANSACTION;

        SELECT stock_id, myqty

        INTO v_stock_id, v_myqty

        FROM mystock

        WHERE item_id = p_item_id

        ORDER BY stock_id

        LIMIT 1

        FOR UPDATE;

        IF v_not_found = 1 THEN

            ROLLBACK;

            SELECT 

                0 AS success,

                '找不到此商品' AS message,

                p_item_id AS item_id,

                p_order_qty AS order_qty,

                NULL AS remaining_qty;

        ELSEIF v_myqty < p_order_qty THEN

            ROLLBACK;

            SELECT 

                0 AS success,

                '庫存不足,無法訂購' AS message,

                p_item_id AS item_id,

                p_order_qty AS order_qty,

                v_myqty AS remaining_qty;

        ELSE

            UPDATE mystock

            SET myqty = myqty - p_order_qty

            WHERE stock_id = v_stock_id;

            COMMIT;


            SELECT 

                1 AS success,

                '訂購成功,庫存已扣除' AS message,

                p_item_id AS item_id,

                p_order_qty AS order_qty,

                v_myqty - p_order_qty AS remaining_qty;

        END IF;

    END IF;

END$$


DELIMITER ;

當有訂單下來,就可以使用指令 : CALL sp_order_mystock('1000', 3);

就會從 mystock 去扣除訂單數量,如下圖 : 


使用了 stored procedure、stored function 的好處就是,以後要處理同樣的事情,就可以重複使用,不需要再另外寫,只要維護好 stored procedure、stored function 即可。


[訂餐系統]的程序就展示如下 : 

(1) 開始訂餐前先讓員工選擇訂單處理方式,如下圖 : 


(2) 然後就開始從菜單上挑選餐點進入購物車,如下圖 : 


(3) 由於員工選擇規則二,因此若選到缺貨的餐點就自動略過,先處理有庫存的餐點,如下圖 : 


 

(4) 如果員工選擇規則三,當遇到缺庫存,就由員工確認要如何處理訂單,如下圖 : 


(5) 若員工尚未確認下單,有庫存的餐點會保留五分鐘,如下圖 : 


保留五分鐘內,如果員工確認 ~ 則有庫存的餐點訂單會成立,放棄沒有庫存的餐點。

(6) 如果員工選擇規則一,當遇到某餐點缺庫存,就整張訂單失敗,如下圖 : 


(7) 訂餐時間結束,最後就可以確認全部訂單,如下圖 : 


上面可以看到在三種訂餐規則中,規則一只是用來凸顯規則二、三,因為應該沒人會選這個規則 : 只要一個餐點沒有庫存,沒有取消就不能繼續往後進行。

規則二則是由系統幫你處理餐點沒有庫存時,自動取消。但是這樣子,點餐的人如果沒有再檢視一次,會沒有看到「餐點沒有庫存」。

因此最合理的方式應該是規則三 : 當遇到缺庫存,就由員工確認要如何處理訂單,取消整張訂單? 還是先接受有庫存的餐點? 

因此把所有可能性都考量進去,你在實際操作時就會找出比較合理的程序。

後續我們再來處理訂餐系統的供餐 (清點合作店家送來餐點)、取餐 (確認員工是否拿走餐點),以及 [訂單管理系統]、[員工部門管理系統]、[合作店家與菜單管理系統]。

張貼留言

0 留言