Thiết kế hệ thống Giỏ hàng (Cart) chuẩn E-commerce: Bài toán Tồn kho và Tính toán động
để thiết kế một chức năng Giỏ hàng (Shopping Cart) chuẩn mực cho hệ thống E-commerce, chúng ta không thể làm theo kiểu "mì ăn liền" (lưu vào Session hoặc Cookie). Ở các hệ thống thực tế, Giỏ hàng phải được đồng bộ qua Database để user đăng nhập trên điện thoại hay máy tính đều thấy cùng một dữ liệu.
Hơn thế nữa, một giỏ hàng "có não" phải biết kiểm tra số lượng tồn kho (Stock) và tính toán giá tiền động (Dynamic Calculation) thay vì lưu chết giá tiền vào database (bởi vì giá sản phẩm có thể thay đổi bất cứ lúc nào trước khi user thanh toán).
Dưới đây là một bài viết hoàn toàn mới, hướng dẫn chi tiết từ A-Z cách xây dựng module Giỏ hàng với kiến trúc Service Pattern và API Resource trong Laravel.
Lời mở đầu: Những cái bẫy khi làm Giỏ hàng
Nhiều lập trình viên khi mới làm chức năng Giỏ hàng thường mắc 2 sai lầm chí mạng:
- Lưu giá tiền (
price) vào bảngcart_items: Hôm nay sản phẩm giá 100k, user bỏ vào giỏ. Ngày mai hết khuyến mãi, giá lên 150k. Nếu bạn lưu cứng số 100k vào giỏ hàng, công ty sẽ lỗ nặng. Nguyên tắc: BảngCartchỉ lưuproduct_idvàquantity. Giá tiền phải được query trực tiếp từ bảng Product ở thời điểm hiện tại. - Không check tồn kho khi thêm: User bấm thêm 10 cái iPhone vào giỏ, hệ thống vẫn cho phép dù trong kho chỉ còn 2 cái. Đến lúc thanh toán mới báo lỗi sẽ tạo ra trải nghiệm cực kỳ ức chế (UX tồi).
Hôm nay, chúng ta sẽ thiết kế một luồng Giỏ hàng bằng Database, tuân thủ nghiêm ngặt các rào cản về logic kinh doanh (Business Logic).
Bước 1: Khởi tạo dự án & Thiết kế Cơ sở dữ liệu
Tạo dự án mới:
laravel new enterprise-cart
cd enterprise-cart
Chúng ta cần 3 bảng: products (Sản phẩm), carts (Giỏ hàng của User) và cart_items (Chi tiết các món trong giỏ).
Tạo các file Migration và Model:
php artisan make:model Product -m
php artisan make:model Cart -m
php artisan make:model CartItem -m
1. Bảng products:
// database/migrations/xxxx_create_products_table.php
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->decimal('price', 15, 2);
$table->integer('stock')->default(0); // Số lượng tồn kho cực kỳ quan trọng
$table->timestamps();
});
}
2. Bảng carts và cart_items:
(Ta gom chung vào một migration cho tiện)
// database/migrations/xxxx_create_carts_table.php
public function up(): void
{
Schema::create('carts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();
});
Schema::create('cart_items', function (Blueprint $table) {
$table->id();
$table->foreignId('cart_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->integer('quantity');
$table->timestamps();
// Một sản phẩm chỉ xuất hiện 1 dòng trong giỏ hàng (nếu thêm nữa thì tăng quantity)
$table->unique(['cart_id', 'product_id']);
});
}
Chạy php artisan migrate để tạo bảng.
Bước 2: Khai báo Relationship (Model) Các Model cần biết chúng có quan hệ gì với nhau để sau này dùng Eager Loading chống N+1 query.
app/Models/Cart.php
class Cart extends Model
{
protected $fillable = ['user_id'];
public function items()
{
return $this->hasMany(CartItem::class);
}
}
app/Models/CartItem.php
class CartItem extends Model
{
protected $fillable = ['cart_id', 'product_id', 'quantity'];
public function product()
{
return $this->belongsTo(Product::class);
}
}
Bước 3: Tạo CartService - Bộ não của hệ thống
Tuyệt đối không nhét logic kiểm tra tồn kho vào Controller. Hãy tạo một Service riêng.
mkdir app/Services
Tạo file app/Services/CartService.php:
namespace App\Services;
use App\Models\Cart;
use App\Models\CartItem;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
use Exception;
class CartService
{
/**
* Lấy giỏ hàng của user (Nếu chưa có thì tự động tạo mới)
*/
public function getCartForUser($userId): Cart
{
return Cart::firstOrCreate(['user_id' => $userId]);
}
/**
* Thêm sản phẩm vào giỏ hàng
*/
public function addToCart($userId, $productId, $quantity = 1): array
{
$product = Product::findOrFail($productId);
// 1. Kiểm tra tồn kho tổng thể
if ($product->stock < $quantity) {
throw new Exception("Sản phẩm '{$product->name}' chỉ còn {$product->stock} sản phẩm trong kho.");
}
return DB::transaction(function () use ($userId, $product, $quantity) {
$cart = $this->getCartForUser($userId);
// Tìm xem sản phẩm đã có trong giỏ chưa
$cartItem = CartItem::where('cart_id', $cart->id)
->where('product_id', $product->id)
->first();
if ($cartItem) {
// 2. Nếu đã có, kiểm tra tổng quantity có vượt tồn kho không
$newQuantity = $cartItem->quantity + $quantity;
if ($product->stock < $newQuantity) {
throw new Exception("Không thể thêm. Bạn đã có {$cartItem->quantity} sản phẩm này trong giỏ, kho chỉ còn {$product->stock}.");
}
// Cập nhật số lượng
$cartItem->update(['quantity' => $newQuantity]);
} else {
// 3. Nếu chưa có, tạo dòng mới
CartItem::create([
'cart_id' => $cart->id,
'product_id' => $product->id,
'quantity' => $quantity,
]);
}
return ['success' => true, 'message' => 'Đã thêm vào giỏ hàng.'];
});
}
}
Bước 4: API Resource - Tính toán "Động" lúc trả dữ liệu
Như đã nói, giá tiền và tổng tiền phải được tính realtime. Ta dùng API Resource để biến đổi dữ liệu.
php artisan make:resource CartResource
php artisan make:resource CartItemResource
app/Http/Resources/CartItemResource.php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class CartItemResource extends JsonResource
{
public function toArray($request)
{
$price = $this->product->price;
$total = $price * $this->quantity;
return [
'item_id' => $this->id,
'product_id' => $this->product_id,
'name' => $this->product->name,
'unit_price' => (float) $price,
'quantity' => $this->quantity,
'total_price' => (float) $total,
];
}
}
app/Http/Resources/CartResource.php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class CartResource extends JsonResource
{
public function toArray($request)
{
// Tính tổng tiền của toàn bộ giỏ hàng
$grandTotal = $this->items->sum(function ($item) {
return $item->product->price * $item->quantity;
});
return [
'cart_id' => $this->id,
'grand_total' => (float) $grandTotal,
// Trả về danh sách items đã được format
'items' => CartItemResource::collection($this->items),
];
}
}
Bước 5: Controller và Routing
php artisan make:controller Api/CartController
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\CartService;
use App\Http\Resources\CartResource;
use Exception;
class CartController extends Controller
{
protected CartService $cartService;
public function __construct(CartService $cartService)
{
$this->cartService = $cartService;
}
// Xem giỏ hàng
public function index(Request $request)
{
$cart = $this->cartService->getCartForUser($request->user()->id);
// Eager load product để chống N+1 Query
$cart->load('items.product');
return response()->json([
'success' => true,
'data' => new CartResource($cart)
]);
}
// Thêm vào giỏ hàng
public function add(Request $request)
{
$request->validate([
'product_id' => 'required|exists:products,id',
'quantity' => 'required|integer|min:1'
]);
try {
$result = $this->cartService->addToCart(
$request->user()->id,
$request->product_id,
$request->quantity
);
return response()->json($result, 200);
} catch (Exception $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 400);
}
}
}
Mở routes/api.php đăng ký:
use App\Http\Controllers\Api\CartController;
Route::middleware('auth:sanctum')->group(function () {
Route::get('/cart', [CartController::class, 'index']);
Route::post('/cart/add', [CartController::class, 'add']);
});
Bước 6: Test hệ thống với Postman
Trước khi test, ta cần dọn sẵn dữ liệu. Mở terminal gõ php artisan tinker:
// Tạo User
$user = \App\Models\User::factory()->create();
echo $user->createToken('Postman')->plainTextToken;
// => COPY TOKEN NÀY! VD: 1|ABCXYZ...
// Tạo 2 Sản phẩm (Chú ý số lượng tồn kho)
\App\Models\Product::create(['name' => 'Bàn phím cơ', 'price' => 1000000, 'stock' => 5]);
\App\Models\Product::create(['name' => 'Chuột Gaming', 'price' => 500000, 'stock' => 20]);
Thoát Tinker và bật server: php artisan serve
Kịch bản 1: Thêm Bàn phím cơ vào giỏ (Số lượng 2)
- Method:
POST - URL:
[http://127.0.0.1:8000/api/cart/add](http://127.0.0.1:8000/api/cart/add) - Headers:
Authorization:Bearer {token} | Accept: application/json - Body (JSON):
{
"product_id": 1,
"quantity": 2
}
- Kết quả:
{
"success": true,
"message": "Đã thêm vào giỏ hàng."
}
Kịch bản 2: Bẫy tồn kho (Thêm tiếp 4 cái Bàn phím)
Kho chỉ có 5 cái, bạn đã thêm 2 cái ở Kịch bản 1. Giờ gửi request thêm 4 cái nữa (Tổng = 6 > 5).
Nhấn SEND với "quantity": 4.
- Kết quả: Hệ thống lập tức tung khiên chặn lại!
{
"success": false,
"message": "Không thể thêm. Bạn đã có 2 sản phẩm này trong giỏ, kho chỉ còn 5."
}
Kịch bản 3: Xem danh sách Giỏ hàng (Tính toán động)
- Method:
GET - URL:
[http://127.0.0.1:8000/api/cart](http://127.0.0.1:8000/api/cart) - Headers:
Authorization:Bearer {token} - Kết quả: Resource tính toán
total_pricevàgrand_totaltrực tiếp cực kỳ mượt mà.
{
"success": true,
"data": {
"cart_id": 1,
"grand_total": 2000000,
"items": [
{
"item_id": 1,
"product_id": 1,
"name": "Bàn phím cơ",
"unit_price": 1000000,
"quantity": 2,
"total_price": 2000000
}
]
}
}
Tổng kết
Xây dựng module Giỏ hàng chuẩn Enterprise đòi hỏi sự cẩn thận ở 3 yếu tố:
- Dữ liệu toàn vẹn: Không lưu thừa giá tiền vào DB để tránh sai lệch giá.
- Khóa chặn logic (Guard): Validate tồn kho (Stock) trước khi tác động vào Database.
- Clean Code: Gom tính toán toán học (
sum, nhân giá) vào tầng API Resource để tách biệt hoàn toàn với tầng Model.
Kiến trúc này đảm bảo hệ thống của bạn chịu tải tốt, dữ liệu luôn chính xác và frontend lấy data vô cùng nhàn hạ. Chúc bạn áp dụng thành công!
All rights reserved