資料模型基礎
SalePage/SaleProduct/SaleProductSKU:一頁、多商品、多規格的三層結構
加入購物車之所以要join三張表才能判斷一個SKU能不能加入,是因為商品資料本身就是分三層設計的:SalePage(商品頁)管頁面層級的銷售條件,SaleProduct(商品)管「這是主件還是贈品」的角色,SaleProductSKU(規格)管實際可購買、有獨立庫存與售價的最小單位。
商品主分身類目ShopCategorySalePage
n:1
商品頁SalePage
1:n
商品頁配送方式SalePageDeliverType
商品頁付款方式SalePagePayType
1:1
商品SaleProduct
1:n
商品頁SKUSaleProductSKU
1:1
庫存ProductStock
SalePage 商品頁1 筆
掌管頁面層級的銷售條件:ListingStartDateTimeListingEndDateTime(上下架時間)、SellingStartDateTimeSellingEndDateTime(銷售起訖日期)、SellingStartTimeSellingEndTime(每日銷售時段)、IsClosed(是否關閉)、Modes(顯示模式,例如是否為組合商品)、運費與付款方式設定。一個商品頁只有一筆。
商品
SaleProduct 商品/角色1 對 1
SaleProductSalePage一對一關係,一個 SalePageId 底下只會對應到一筆 SaleProduct。這一筆用 IsMajor(是否為主件)、IsGift(滿額贈贈品)、IsSalePageGift(買就送贈品)、IsExtra(加價購)等布林欄位標記這個商品頁對應商品的角色,也帶著 IsExpress(快閃商品)欄位。真正「同一頁掛多個商品」的情境(例如贈品),是靠 SalePageGift 這張獨立的買就送贈品主檔搭配另一個 SalePage_Id 來實現,而不是同一個 SalePageId 掛多筆 SaleProduct
規格
SaleProductSKU 規格/庫存單位1 對多
同一個 SaleProductId 底下可以掛多筆 SaleProductSKU,每一筆是真正可被購買的最小單位,各自有獨立的 Price(售價)、OuterId(供應商料號)、IsShow(是否顯示),並關聯到 ProductStock 取得實際庫存量。加入購物車時傳入的 SkuId 鎖定的就是這一層。
層級關聯鍵這一層決定什麼
SalePageSalePage_Id頁面能不能賣:上下架時間、銷售時段、運費付款方式設定、是否為組合商品
SaleProductSaleProduct_SalePageIdSalePage_Id(1:1)這個商品頁對應商品的角色:主件、滿額贈贈品、買就送贈品、加價購
SaleProductSKUSaleProductSKU_SaleProductIdSaleProduct_Id(1:n)實際被購買的規格:售價、庫存、供應商料號、是否顯示
🧩

實際舉例:主件與贈品是兩個各自獨立、卻靠 SalePageGift 綁在一起的商品頁,以下用小型關聯圖、卡片對照、SKU 對照表、加入購物車結果分支四個角度拆解同一個案例。

主件商品頁SalePage(Id=1001)
1:1
SalePageGiftSalePageId=1001
記錄
贈品商品頁SalePage(Id=1002)
主件:Nike 運動鞋
商品頁 SalePage_Id=1001,對應唯一一筆 SaleProductIsMajor=trueSaleProduct_Id=5001),底下掛兩筆 SaleProductSKU:黑色 42 號與白色 40 號。
贈品:運動襪
另一個獨立商品頁 SalePage_Id=1002,對應另一筆 SaleProductIsGift=trueSaleProduct_Id=5002),庫存與售價各自獨立管理,不掛在主件底下。
SkuId規格庫存來源商品頁
9001黑色 42 號20 雙主件 SalePage_Id=1001
9002白色 40 號0 雙主件 SalePage_Id=1001
(贈品規格)運動襪各自獨立管理贈品 SalePage_Id=1002
  • SkuId=9001 加入購物車:庫存足夠,驗證全數通過,成功加入。
  • SkuId=9002 加入購物車:庫存為 0,在庫存驗證這一關被擋下。
  • 誤帶贈品商品頁(SalePage_Id=1002)底下任一 SkuId:在角色驗證直接被擋下,因為贈品只能透過自動預選機制進入購物車,不能走一般的加入購物車入口。
展開:SalePageGift 怎麼把兩者串起來

兩個商品頁透過 SalePageGift 記錄串起關聯:SalePageGift_SalePageId=1001 指向鞋子頁面,代表買鞋子送這個贈品。這筆記錄只負責標記「誰跟誰綁在一起送」,不會讓兩個商品頁共用同一份庫存或規格資料。

時間欄位設計
為什麼會有 Listing 與 Selling 兩組時間欄位
Listing(上下架)
vs
Selling(銷售)
ListingSalePage_ListingStartDateTimeSalePage_ListingEndDateTime控制商品頁在前台看不看得到,也就是是否出現在商品列表、搜尋結果、分類頁。這是給營運人員排程上下架用的可見性開關,跟能不能結帳無關。
SellingSalePage_SellingStartDateTimeSalePage_SellingEndDateTime(加上每日 SalePage_SellingStartTimeSalePage_SellingEndTime):控制商品能不能被下單,也就是是否可以加入購物車、送出訂單。這是給促銷檔期、限時搶購用的交易開關,跟頁面看不看得到無關。
判斷結果條件公式
看得到商品(上架中)now >= SalePage_ListingStartDateTime && now < SalePage_ListingEndDateTime
買得到商品(開賣中)now >= SalePage_SellingStartDateTime && now < SalePage_SellingEndDateTime(另外每日 SalePage_SellingStartTimeSalePage_SellingEndTime 還要疊加判斷當下時段)
搶先曝光SalePage_ListingStartDateTime == now(上架起始時間點當下,商品剛好開始被看見)
商品頁關閉(總開關)SalePage_IsClosed == true,一旦成立會直接蓋過上面所有判斷
⚠️

兩者可以不同步,這是刻意設計:常見情境是預告型商品頁,商品頁已經上架(SalePage_ListingStartDateTime 已過,看得到頁面、看得到介紹圖文),但銷售時間還沒到(SalePage_SellingStartDateTime 是未來時間),此時會回傳尚未開賣,頁面顯示即將開賣但無法加入購物車。反過來也可能發生:頁面已經下架(SalePage_ListingEndDateTime 已過,不再出現在列表或搜尋),但如果透過直接連結或收藏連到頁面,仍以 Selling 時間為準決定能不能加入購物車。

🧩

驗證順序:先檢查 SalePage_IsClosed,一旦為 true 直接判定此商品頁已關閉,不再往下比對任何時間欄位;接著才檢查 SalePage_ListingStartDateTimeSalePage_SellingStartDateTimeSellingStartTimeSellingEndTime,任一未到就回傳尚未開賣;最後檢查 SalePage_SellingEndDateTime 是否已過,過了就回傳過銷售時間。可加入購物車的判斷只吃 Selling 系列時間與 IsClosedListing 時間本身不直接卡加入購物車,只影響前台是否顯示這個商品頁。

贈品怎麼跟主件綁在一起
SalePageGift:主件與贈品怎麼關聯
贈品不是掛在主件同一個 SalePage 底下的另一筆 SaleProduct,而是它自己完整的一套 SalePageSaleProductSaleProductSKU,透過 SalePageGift 這張獨立主檔表做誰跟誰綁在一起送的邏輯關聯,庫存、售價、上下架時間全部各自獨立管理。
主件商品頁SalePage(Id=1001)
1:1
買就送贈品主檔SalePageGift
記錄
贈品商品頁SalePage(Id=1002)
欄位說明
SalePageGift_Id買就送贈品主檔序號(唯一鍵建在這個欄位上)
SalePageGift_SalePageId外鍵,指回主件商品頁的 SalePage_Id
SalePageGift_ValidFlag是否生效
⚠️

關聯粒度:SalePageGift 唯一索引建在 SalePageGift_Id 本身,不是建在 SalePageGift_SalePageId 上,代表資料表結構本身沒有強制一個主件商品頁只能有一筆買就送設定;實務上一個主件商品頁能否掛多筆買就送規則,要以商品維運後台的業務邏輯為準,不能只靠這張表的索引推論。

🧩

舉例:「Nike 運動鞋」主件商品頁 SalePage_Id=1001(對應 SaleProduct_Id=5001IsMajor=true)與贈品「運動襪」商品頁 SalePage_Id=1002(對應 SaleProduct_Id=5002IsGift=true)是兩個完全獨立、各自一對一對應 SaleProduct 的商品頁,也各自有自己的 SaleProductSKU 與庫存。SalePageGift 表裡會有一筆 SalePageGift_SalePageId=1001 的紀錄,代表 1001 這支主件商品頁有買就送活動;實際送哪一個贈品商品頁,則由更細的贈品明細層級進一步指定。

小結:SalePageGift 只回答這個主件商品頁有沒有買就送活動這個開關性問題,不是把贈品塞進主件的 SaleProduct 清單裡。贈品永遠是它自己獨立的商品頁、商品、規格,只是邏輯上被 SalePageGift(以及更細的贈品明細)標記成跟某個主件綁在一起送。加入購物車時如果誤帶到贈品商品頁的 SkuId,會在角色驗證直接被擋下,因為贈品只能透過自動預選機制進入購物車,不能走一般的加入購物車入口。

庫存資料模型
ProductStock 與 SaleProductSKU:規格定義賣什麼,庫存定義還剩多少
SaleProductSKUProductStock一對一關係,跟 SaleProductSaleProductSKU 那種一對多不同。SaleProductSKU 負責這個規格怎麼賣(售價、料號、顯示狀態),ProductStock 負責這個規格還剩多少能賣(庫存數字本身),兩張表刻意拆開維護。
1 對 1 關聯 外鍵:ProductStock_SaleProductSKUId 唯一索引保證不會一對多
  • SaleProductSKU(規格主體):售價 Price、供應商料號 OuterId、是否顯示 IsShow、單次限購量 OnceQty,這些是商品目錄概念,變動頻率低。
  • ProductStock(庫存帳):上架總量 TotalQty、累積訂單量 RegQty、取消量 CancelQty、目前可賣量 SellingQty,這些是庫存帳務概念,會隨每一筆下單或取消頻繁異動。
  • 更新頻率差異巨大:庫存數字高頻寫入,規格資料低頻異動,分表避免互相鎖等待、拖慢彼此。
  • 語意單一職責:商品目錄(規格)與庫存帳務(數量),本來就是兩個業務子領域。
  • 可延伸性:ProductStock_GoodsStockId 指向更底層貨品庫存池,多個 SKU 可能共用同一批貨的庫存,拆開才能讓庫存邏輯獨立演進。
呼叫端
Repository
DB:csp_GetProductStockList
回傳結果
驗證可售庫存
1 秒記憶體快取
未命中 →
2 JOIN 兩表 + 限購封頂運算
3 SellingQty 供驗證使用
JOIN 條件
ProductStock_SaleProductSKUId = SaleProductSKU_Id 一對一關聯,並限定兩表都要 ValidFlag = 1(有效)。
可售量怎麼算
IsShow = 0 直接回傳 0IsShow = 1 時取庫存量與單次限購量 OnceQty 兩者較小值,除非是定期購自動建單模式則不封頂。
最終怎麼用
驗證只看這個計算後的 SellingQty < 1 就擋下,不是直接看原始庫存欄位。

小結:庫存資料在被拿來驗證之前,已經在資料庫層先做過顯示狀態與單次限購的運算,取到的 SellingQty 是這次可以買多少而非倉庫裡實際還有多少,兩者是不同層次的數字。

寫入流程
商品怎麼寫進購物車:加入購物車 API 的四道驗證
加入購物車這支 API 收到請求後,會依序跑過四道驗證,全數通過才會真正把商品寫進 ShoppingCart 表。
1
驗證商品頁

檢查商品是否存在、是否為贈品、商品頁是否已停售。

2
驗證容量上限

檢查商店設定的商品種類上限與總數量上限。

3
驗證可售庫存

確認商品剩餘可售數量足夠這次加入的數量。

4
驗證組合商品

確認加購品或組合商品的子項設定正確。

寫入 ShoppingCart

四道驗證全數通過後,組裝購物車項目清單(主商品與加購品或組合商品子項各帶擴充資訊,標記主件或子件與分組編號),真正寫進資料庫。若開啟數量累加設定,加入前會先查出現有同商品數量做累加,否則直接覆蓋成請求的數量。

判斷情境依據結果
同一 SalePageIdSkuId 已存在於 ShoppingCart依商店代碼、會員或訪客識別、有效旗標篩選既有紀錄走更新:覆蓋或累加既有數量
尚未存在於 ShoppingCart同上篩選條件查無紀錄走新增:插入新的一筆
兩種情況的識別鍵登入會員用會員編號,未登入訪客用未登入識別碼與後續讀取購物車時使用的識別鍵完全一致
ℹ️

與讀取購物車的關係:加入購物車(寫入 ShoppingCart 表)與讀取購物車(讀取該表並試算金物流)是分工明確的兩支 API。每次點擊加入購物車都會即時寫入資料庫,之後任何時間點讀取購物車,都是拿同一組識別鍵去查詢當下資料庫裡實際存在的商品,而不是靠前端把商品清單傳過來。

規則層設計
組合商品:不是一支商品,是一套規則
組合商品不會改變 SalePageSaleProductSaleProductSKU 的資料結構,主商品與子商品都是各自真實存在、可獨立查詢的商品頁。組合商品只是在這些真實商品頁上疊加一層誰能跟誰綁、要買幾件、算多少錢的規則,這層規則由外部規則服務提供,加入購物車當下逐項核對。

一次加入購物車呼叫的外層 SalePageIdSkuId 就是主商品(組合套餐本身)的真實商品頁;子商品清單裡每一筆的 SalePageIdSaleProductSKUId 則是各個子商品各自真實的商品頁,兩者都不是虛擬編號。驗證通過後,購物車裡實際會產生主商品加上 N 個子商品共 N+1 筆項目,靠主商品的擴充資訊指向子商品建立父子關係,結帳只收主商品價格。

Block(區塊)是組合規則裡一個可替換選擇的商品分類槽位,不是指某個特定商品。例如早餐超值組合可以有 Block A(主餐:漢堡或三明治擇一)、Block B(飲料:紅茶或奶茶擇一)。每個 Block 各自定義必買與限購件數,以及這格允許哪些商品頁或規格。子商品宣告自己屬於哪個 Block,加入購物車時再拿這個 Block 的規則去核對範圍與數量。

前端
購物車服務
組合規則服務
寫入購物車
傳入主商品 SalePageId/SkuId + 子商品清單
判定組合模式
欄位檢查 →
驗證組合子項
取得組合規則
回傳規則 →
逐項核對
主商品 + N 個子商品各成一筆
父子關聯
排序後寫入
角色SalePageIdSkuId商品名稱售價
主商品(組合套餐頁)800190001早餐超值組合199
Block A 子商品300130011牛肉漢堡89(單買參考價)
Block B 子商品300230021中杯奶茶45(單買參考價)
ℹ️

驗證重點:子商品總數量需等於必買件數乘以購買組數,每個子商品需對得上某個 Block,該 Block 允許的範圍需包含這個子商品的商品頁(若限定規格清單還需包含規格編號),數量需落在該 Block 的必買與限購區間內,最後主商品價格需小於等於子商品原價總和,任一項不符即擋下。

小結:組合商品是規則驅動的商品綁定,判斷依據不在購物車自己的資料庫,而是外部規則服務提供的規則。購物車只負責把前端傳入的子商品清單,拿去對規則逐項驗證與排序,資料庫裡看到的仍是主商品與子商品各自獨立的購物車列。