Xây dựng website bán hàng bằng Laravel - Thiết lập và quản lý đơn hàng

Ngày đăng
Tác giả
Tran Thanh Long

Quản lý đơn hàng là quy trình back-end để quản lý và kiện toàn các đơn hàng trực tuyến. Việc này bao gồm mọi thứ từ định tuyến đơn hàng, in nhãn vận chuyển cho đến đến trả hàng cho khách. Trong bài viết của ngày hôm nay chúng ta sẽ cùng tìm hiểu cách để tạo và quản lý các đơn đặt hàng.


E-commerce với Laravel là loạt bài ghi lại toàn bộ quá trình xây dựng hệ thống thương mại điện tử được thực hiện bởi Transmoni team nhắm hướng dẫn mọi người làm quen với Laravel, Livewire, Alpine.js và Tailwind CSS. Hiện dự án đang được cập nhật liên tục, toàn bộ mã nguồn của dự án sẽ được công khai miễn phí theo hình thức mã nguồn mở trên trang Github của Transmoni sau khi hoàn tất loạt bài hướng dẫn này.

Mỗi công ty có một cách riêng để quản lý đơn hàng của mình. Điều này có thể dựa trên khách hàng, địa điểm bán hàng, quy trình trả hàng và nhà cung cấp. Mặc dù các quy trình có thể khác nhau, nhưng hầu hết các quy trình thực hiện đơn đặt hàng đều bao gồm các bước sau:

  • Công ty nhận được đơn đặt hàng từ khách hàng.

  • Công ty nhập đơn đặt hàng vào hệ thống.

  • Khách hàng nhận được thông báo rằng công ty đã nhận được đơn đặt hàng.

  • Đơn đặt hàng được chuyển đến kho hàng hoặc cửa hàng gần nhất.

  • Công ty chuẩn bị đơn hàng và in tem nhãn vận chuyển.

  • Công ty vận chuyển đơn hàng.

  • Khách hàng nhận được thông báo rằng đơn đặt hàng đang được chuyển đến.

  • Đơn đặt hàng được gửi đến tay khách hàng.

  • Kết thúc quy trình xử lý đơn hàng.

Vì hiện tại chúng ta mới chỉ hoàn thành các tính năng cơ bản của một hệ thống TMDT do đó trong khuôn khổ bài viết này, chúng ta sẽ tiến hành xử lý các bước bao gồm tiếp nhận đơn đặt hàng của khách và lưu trữ trên hệ thống cơ sở dữ liệu. Tiếp sau đó sẽ là tạo các Livewire components để xem danh sách các đơn hàng cũng như quản lý thông tin chi tiết của từng đơn hàng. OK bắt đầu nhé!

Xây dựng cơ sở dữ liệu đơn hàng

Việc đầu tiên là tạo các bảng trong cơ sở dữ liệu để lưu trữ thông tin đơn hàng bao gồm:

  • orders để lưu các thông tin cơ bản bao gồm khóa ngoại đến bảng users, trạng thái đơn hàng, phương thức thanh toán...

  • order_items để lưu thông tin danh sách các sản phẩm trong đơn hàng.

Ngoài ra thì chúng ta cũng cần thêm một bảng riêng là addresses sử dụng cách thiết lập mối quan hệ đa hình (polymorphic relationship). Bảng này sẽ lưu lại thông tin địa chỉ của từng đơn hàng ví dụ: địa chỉ thanh toán hoặc địa chỉ nhận hàng... Và sau này chúng ta có thể sẽ tạo tính năng cho phép khách hàng có thể tạo sổ địa chỉ để không phải nhập lại địa chỉ trong mỗi lần đặt hàng, do đó việc sử dụng polymophic relationship sẽ giúp chúng ta quản lý địa chỉ một cách tập trung hơn thay vì chia ra nhiều bảng với quan hệ n-n.

Bắt đầu model và các file migration để tạo bảng cơ sở dữ liệu với make:migration

php artisan make:model Order -mfs
php artisan make:model OrderItem -mfs

Và tiến hành khai báo các cột dữ liệu như sau tại:

database/migrations/xxxx_xx_xx_xxxxxx_create_orders_table.php

public function up()
{
    Schema::create('orders', function (Blueprint $table) {
        $table->id();
        $table->foreignIdFor(\App\Models\User::class)->constrained()->cascadeOnDelete();
        $table->string('status')->default('awaiting_payment');
        $table->string('payment_method')->default('cash_on_delivery');
        $table->timestamps();
    });
}

Trên đây chúng ta sẽ có các cột:

  • status để quản lý trạng thái của đơn hàng với giá trị mặc định là chờ thanh toán.

  • payment_method để quản lý phương thức thanh toán với giá trị mặc định là cash_on_delivery (COD), hay dịch ra là trả tiền mặt khi nhận hàng.

database/migration/xxxx_xx_xx_xxxxxx_create_order_items_table.php

public function up()
{
    Schema::create('order_items', function (Blueprint $table) {
        $table->id();
        $table->foreignIdFor(\App\Models\Order::class)->constrained()->cascadeOnDelete();
        $table->foreignIdFor(\App\Models\SKU::class, 'sku_id')->constrained();
        $table->string('name');
        $table->decimal('price', 12)->default(0);
        $table->integer('quantity')->default(1);
        $table->decimal('subtotal', 12)->storedAs('price * quantity');
        $table->timestamps();
    });
}

Với bảng này chúng ta sẽ không link trực tiếp đến bảng products mà thay vào đó là tới bảng skus vì nó có chứa thông tin chi tiết bao gồm giá cả, tồn kho, thuộc tính..., và vì bảng skus của chúng ta đã thiết lập quan hệ 1-n với products nên nếu cần thông tin của sản phẩm chúng ta chỉ việc eager load với 'sku.product' là có thể lấy được các thông tin cần thiết từ bảng products.

Ngoài ra chúng ta sẽ lưu lại tên, giá bán của sản phẩm với lý do nếu sản phẩm có thay đổi về các giá trị này cũng không gây ảnh hưởng đến các thông tin trong đơn hàng đã hoàn thành. Lấy ví dụ sản phẩm A được bán với giá 250.000 VND tại thời điểm khách hàng tạo đơn hàng, tuy nhiên một thời gian sau giá của nó có thay đổi tăng lên là 270.000 VND thì lúc này các đơn hàng đã hoàn thành sẽ không bị ảnh hưởng bởi khoản chênh lệnh trên vì nó đã được lưu trữ riêng, và nếu không lưu lại sẽ dẫn đến sai lệch về các báo cáo trên toàn hệ thống bán hàng.

Tiếp đến chúng ta sẽ có cột subtotal (tổng phụ), nó được tính bằng cách lấy giá trị của sản phẩm nhân với số lượng cần mua. Và trong loạt bài hướng dẫn này chúng ta đang sử dụng MySQL nên sẽ được hỗ trợ cột dữ liệu theo dạng ảo hóa (virtual column) với tính năng tự động kích hoạt (trigger) các biểu thức đơn giản nên tôi sẽ cho nó tự tính số tiền tổng phụ này mỗi khi có thay đổi về một trong hai giá trị là giá bán và số lượng như bạn thấy ở trên.

Bạn có thể bỏ cột subtotal và thay vào đó sử dụng Model Accessor để tính tổng phụ nếu không sử dụng MySQL làm cơ sở dữ liệu.

Tiếp theo tiến hành khai báo mối quan hệ của các bảng trong model

App/Models/Order.php

class Order extends Model
{
    use HasFactory;

    public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function orderItems(): \Illuminate\Database\Eloquent\Relations\HasMany
    {
        return $this->hasMany(OrderItem::class);
    }
}

App/Models/OrderItem.php

class OrderItem extends Model
{
    use HasFactory;

    public function order(): \Illuminate\Database\Eloquent\Relations\BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    public function sku(): \Illuminate\Database\Eloquent\Relations\BelongsTo
    {
        return $this->belongsTo(SKU::class);
    }
}

Ok rồi, tiếp đến chúng ta sử dụng Laravel factory để tạo các bản ghi cho đơn hàng.

database/factories/OrderFactory.php

class OrderFactory extends Factory
{
    public function definition()
    {
        return [
            'user_id' => User::factory(),
            'status' => 'awaiting_payment',
            'payment_method' => 'cash_on_delivery',
        ];
    }
}

database/factories/OrderItemFactory.php

class OrderItemFactory extends Factory
{
    public function definition()
    {
        return [
            'order_id' => Order::factory(),
            'sku_id' => SKU::factory(),
            'name' => $this->faker->sentence,
            'price' => $this->faker->numberBetween(10000, 100000),
            'quantity' => $this->faker->numberBetween(1, 5),
        ];
    }
}

và cuối cùng tại database/seeders/DatabtaseSeeder.php chúng ta tạo một loạt các đơn hàng như sau:

public function run()
{
    ...

    Category::factory()->count(3)
            ->has(
                Product::factory()->count(50)
                    ->has(
                        SKU::factory()
                    )
            )
            ->create();
            
    User::factory()->count(5)
        ->has(
            Order::factory()->count(10)
                ->has(
                    OrderItem::factory()
                )
        )->create();
}

Và sử dụng db:seed để tiến hành tạo các bản ghi

php artisan db:seed

Tạo Enum để quản lý trạng thái đơn hàng

Enumeration gọi tắt là Enum là tính năng được ra mắt trong php 8.1, với tính năng này chúng ta có thể liệt kê các giá trị cố định và sử dụng nó để tạo ra các phương thức đi kèm. Bạn có thể tìm hiểu thêm về tính năng này tại: https://php.watch/versions/8.1/enums.

Để thực hiện chúng ta tiến hành tạo một thư mục mới tên là Enums đặt trong thư mục app/ và tiến hành tạo file OrderStatus.php với nội dung như sau:

<?php

namespace App\Enums;

enum OrderStatus: string
{
    case AWAITING_PAYMENT = 'awaiting_payment';
    case PROCESSING = 'processing';
    ...

    public function color(): string
    {
        return match ($this) {
            self::AWAITING_PAYMENT => '#FBBF24',
            self::PROCESSING => '#38BDF8',
            ...
        };
    }

    public function label(): string
    {
        return match ($this) {
            self::AWAITING_PAYMENT => __('Awaiting Payment'),
            self::PROCESSING => __('Processing'),
            ...
        };
    }
}

Quay trở lại với model Order chúng ta khai báo cột status sẽ sử dụng OrderStatus để thực hiện casting:

protected $casts = [
    'status' => OrderStatus::class,
];

Ok và bây giờ với các phương thức đã tạo trên OrderStatus chúng ta có thể gọi bằng cách $order->status->color(), hoặc $order->status->label()... rất tiện đúng không nào.

Tạo Livewire component để quản lý đơn hàng

Tiếp theo sẽ là công đoạn tạo các Livewire component để quản lý đơn hàng, ở đây tôi chia ra làm hai phần là quản lý danh sách toàn bộ đơn hàng và quản lý chi tiết cho từng đơn hàng. Sử dụng livewire:make để tạo các components này:

php artisan livewire:make Admin\\OrderList
php artisan livewire:make Admin\\OrderManager

Và ở đây chúng ta sẽ sử dụng full-page component cho cả danh sách cũng như chi tiết từng đơn hàng do đó cần khai báo route cho nó như sau tại routes/web.php:

Route::get('orders', OrderList::class)->name('orders.index');
Route::get('orders/{order}', OrderManager::class)->name('orders.show');

Quản lý danh sách đơn hàng

Nếu như bạn đã đọc lần lượt từng bài viết trong loạt bài hướng dẫn này thì việc tạo danh sách đơn hàng cũng giống như tạo danh sách Sản phẩm hay Danh mục sản phẩm, chúng ta cũng sử dụng html table để in ra danh sách các đơn hàng cùng với sự hỗ trợ của Livewire WithPagination Trait để phân trang.

Tại App/Http/Livewire/Admin/OrderList.php chúng ta nhúng danh sách các đơn hàng sang phần view như sau:

class OrderList extends Component
{
    use WithPagination;

    public function render()
    {
        return view('livewire.admin.order-list', [
            'orders' => Order::query()->with('user', 'orderItems')->latest()->paginate(),
        ])->layout('layouts.admin');
    }
}

Và với phần view của component này chúng ta làm như sau:

<div>
    <div class="md:flex md:items-center md:justify-between">
        <div class="flex-1 min-w-0">
            <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">Orders</h2>
        </div>
    </div>

    <div class="mt-6 flex flex-col">
        <div class="-my-2 overflow-x-auto -mx-4 sm:-mx-6 lg:-mx-8">
            <div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
                <div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
                    <table class="min-w-full divide-y divide-gray-200">
                        <thead class="bg-gray-50">
                        <tr>
                            <th scope="col" class="px-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
                            <th scope="col" class="px-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
                            <th scope="col" class="px-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
                            <th scope="col" class="px-4 sm:px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Subtotal</th>
                        </tr>
                        </thead>
                        <tbody>
                        @foreach($orders as $order)
                            <tr class="odd:bg-white even:bg-gray-100">
                                <td class="px-4 sm:px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
                                    <a href="{{ route('admin.orders.show', $order) }}" class="hover:text-indigo-500">
                                        {{ $order->id }}
                                    </a>
                                </td>
                                <td class="px-4 sm:px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
                                    {{ $order->user->name }}
                                </td>
                                <td class="px-4 sm:px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
                                    <div class="flex items-center space-x-1">
                                        <span class="block w-2 h-2 rounded-full" style="background-color: {{ $order->status->color() }}"></span>
                                        <span>{{ $order->status->label() }}</span>
                                    </div>
                                </td>
                                <td class="px-4 sm:px-6 py-4 whitespace-nowrap text-right tabular-nums text-sm font-medium text-gray-900">
                                    <x-money :amount="$order->subtotal" :currency="config('app.currency')" />
                                </td>
                            </tr>
                        @endforeach
                        </tbody>
                    </table>
                </div>
            </div>
        </div>
    </div>

    <div class="mt-6">
        {{ $orders->links() }}
    </div>
</div>

Và kết quả của chúng ta khi truy cập /admin/orders:

Quản lý danh sách đơn hàng

Quản lý chi tiết đơn hàng

Phần kế tiếp là quản lý chi tiết đơn hàng, lúc này khi nhấn vào mã số của từng đơn hàng bạn sẽ được chuyển đến trang của chúng mặc dù là còn đang lỗi vì chúng ta chưa khai báo layout. Cùng bắt tay vào hoàn thiện cho component này nhé!

Tại component OrderManager chúng ta tiến hành khai báo biến $order và load các bảng quan hệ của nó như sau:

class OrderManager extends Component
{
    public Order $order;

    public function mount()
    {
        $this->order->load([
            'user', 'orderItems.sku.product.media', 'orderItems.sku.variants.option', 'orderItems.sku.variants.optionValue'
        ]);
    }

    public function render()
    {
        return view('livewire.admin.order-manager')->layout('layouts.admin');
    }
}

Và phần view cũng như trong bài viết về quản lý Sản phẩm chúng ta tiến hành chia phần nội dung ra làm hai cột chính với phần lớn hơn để hiển thị danh sách sản phẩm và phần nhỏ để hiển thị các thông tin khác, cách làm như sau:

<div>
    <div class="md:flex md:items-center md:justify-between">
        <div class="flex flex-1 items-end space-x-5 min-w-0">
            <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
                Order: #{{ $order->id }}
            </h2>
        </div>
    </div>
    <div class="grid grid-cols-3 gap-6">
        <div class="col-span-3 xl:col-span-2">
            <x-card class="-mx-4 mt-5 sm:-mx-0">
                <x-slot name="header">
                    <div class="ml-4 mt-2">
                        <h3 class="text-lg leading-6 font-medium text-gray-900">Products</h3>
                    </div>
                </x-slot>
                <x-slot name="content">
                    <div class="space-y-6 -mx-4 -m-6 sm:-mx-6">
                        <div class="relative overflow-auto">
                            <table class="min-w-full">
                                <thead>
                                    <tr>
                                        <th
                                            scope="col"
                                            class="px-3 py-3 sm:px-6 bg-gray-50 border-b border-gray-300 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
                                        >
                                        </th>
                                        <th
                                            scope="col"
                                            class="px-3 py-3 sm:px-6 bg-gray-50 border-b border-gray-300 text-center text-xs font-medium text-gray-500 uppercase tracking-wider"
                                        >
                                            QTY
                                        </th>
                                        <th
                                            scope="col"
                                            class="px-3 py-3 sm:px-6 bg-gray-50 border-b border-gray-300 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
                                        >
                                            Price
                                        </th>
                                        <th
                                            scope="col"
                                            class="px-3 py-3 sm:px-6 bg-gray-50 border-b border-gray-300 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
                                        >
                                            Subtotal
                                        </th>
                                    </tr>
                                </thead>
                                <tbody>
                                    @foreach($order->orderItems as $item)
                                        <tr>
                                            <td class="px-3 py-4 sm:px-6 border-b border-gray-200 whitespace-nowrap text-sm text-gray-5">
                                                <div class="flex items-center">
                                                    <div class="h-10 w-10 flex-shrink-0">
                                                        <img class="h-10 w-10 rounded-full" src="{{ $item->sku->product->getFirstMediaUrl('media') }}" alt="{{ $item->name }}">
                                                    </div>
                                                    <div class="ml-4 max-w-xs">
                                                        <div class="font-medium text-gray-900 truncate ...">
                                                            <a href="{{ route('admin.products.edit', $item->sku->product) }}">{{ $item->name }}</a>
                                                        </div>
                                                        @if($item->sku->variants)
                                                            <div class="inline-flex space-x-2 divide-x divide-gray-200 text-gray-700">
                                                                @foreach($item->sku->variants as $variant)
                                                                    <p @class(['pl-2' => !$loop->first])>{{ $variant->optionValue->label }}</p>
                                                                @endforeach
                                                            </div>
                                                        @endif
                                                    </div>
                                                </div>
                                            </td>
                                            <td class="px-3 py-4 sm:px-6 border-b border-gray-200 whitespace-nowrap text-center text-sm text-gray-500">
                                                {{ $item->quantity }}
                                            </td>
                                            <td class="px-3 py-4 sm:px-6 border-b border-gray-200 whitespace-nowrap text-right text-sm text-gray-500">
                                                <x-money :amount="$item->price" :currency="config('app.currency')" />
                                            </td>
                                            <td class="px-3 py-4 sm:px-6 border-b border-gray-200 whitespace-nowrap text-right text-sm text-gray-500">
                                                <x-money :amount="$item->subtotal" :currency="config('app.currency')" />
                                            </td>
                                        </tr>
                                    @endforeach
                                </tbody>
                            </table>
                        </div>
                    </div>
                </x-slot>
                <x-slot name="footer">
                    <div class="-mx-4 -my-3 sm:-mx-6 flex justify-end">
                        <dl class="w-full rounded-md sm:bg-gray-50 sm:divide-y sm:divide-gray-200 sm:w-auto">
                            <div class="px-3 py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
                                <dt class="text-sm font-medium text-gray-500">
                                    {{ __('Subtotal') }}
                                </dt>
                                <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 sm:text-right">
                                    <x-money :amount="$order->subtotal" :currency="config('app.currency')" />
                                </dd>
                            </div>
                            <div class="px-3 py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
                                <dt class="text-sm font-medium text-gray-500">
                                    {{ __('Total') }}
                                </dt>
                                <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 sm:text-right">
                                    <x-money :amount="$order->grandtotal" :currency="config('app.currency')" />
                                </dd>
                            </div>
                            <div class="px-3 py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
                                <dt class="text-sm font-medium text-gray-500">
                                    {{ __('Amount paid') }}
                                </dt>
                                <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 sm:text-right">
                                    {{ money(0, 'VND') }}
                                </dd>
                            </div>
                        </dl>
                    </div>
                </x-slot>
            </x-card>
        </div>
        <div class="col-span-3 xl:col-span-1">
        </div>
    </div>
</div>

Và đây là kết quả sau khi truy cập vào phần chi tiết của từng đơn hàng của chúng ta

Quản lý chi tiết đơn hàng

Kết luận

Vậy là chúng ta đã hoàn tất việc xây dựng phần quản lý đơn hàng với những thông tin cơ bản bao gồm danh sách sản phẩm, giá bán, số lượng đặt mua..., tuy nhiên hiện tại chúng ta chưa có các tính năng như thanh toán, vận chuyển... nên bài này sẽ tạm kết thúc tại đây. Hãy cùng đón đọc các bài viết sau để tìm hiểu thêm về những tính năng cần có cho một đơn hàng. Hẹn gặp lại các bạn!

Thông báo biến động số dư qua API & Webhook.

Hỗ trợ tích hợp giao dịch chuyển khoản vào hệ thống thanh toán trực tuyến Tự động, Nhanh chóng, Chính xác.

Đăng ký dùng thử trong 14 ngày
Bài viết mới nhất

Xây dựng website bán hàng bằng Laravel - Dựng trang thông tin Sản phẩm

Để Khách hàng có thể xem và tìm hiểu kỹ hơn về thông tin của một Sản phẩm bao gồm giá bán, số lượng tồn kho hay các mô tả về những đặc tính kỹ thuật ví dụ chất liệu, kiểu dáng... chúng ta sẽ cần đến một trang riêng cho mỗi sản phẩm, hãy cùng tìm hiểu cách để tạo một trang như vậy trong bài viết này.

Xây dựng website bán hàng bằng Laravel - Tổ chức và Phân loại Sản phẩm

Một sản phẩm có thể nằm trong một hoặc nhiều danh mục khác nhau. Để phân phối sản phẩm vào các danh mục chúng ta cần thêm một tính năng trong phần quản lý sản phẩm, hãy cùng tìm hiểu cách thực hiện trong bài viết này nhé!