Xây dựng website bán hàng bằng Laravel - Thuộc tính và các Biến thể của Sản phẩm

TQH 2023-08-30 17:18:34

Thuộc tính (option) sản phẩm là các đặc tính kỹ thuật riêng biệt của từng sản phẩm ví dụ như kích cỡ, màu sắc... Và một sản phẩm thường có một hoặc nhiều thuộc tính khác nhau, các thuộc tính này được kết hợp lại tạo nên một Biến thể (variant) của sản phẩm. Trong bài viết này chúng ta sẽ cùng tìm hiểu cách xây dựng tính năng Thuộc tính và Biến thể cho sản phẩm.

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.

Để bắt đầu trước hết chúng ta sẽ tiến hành tìm hiểu về cách thiết kế cơ sở dữ liệu phục vụ cho tính năng này.

Xây dựng thuộc tính cho sản phẩm

Như đã nói ở trên một sản phẩm sẽ có nhiều thuộc tính khác nhau cho nên nếu bạn chỉ bán riêng một mặt hàng nhất định thì chúng ta có thể quy định cứng các thuộc tính này ở một bảng options. Tuy nhiên với các hệ thống thương mại điện tử lớn sẽ có vô vàn các mặt hàng khác nhau, và bạn không chắc chúng ta sẽ cần thiết lập bao nhiêu thuộc tính mặc định cả. Do vậy chúng ta sẽ xây dựng cơ sở dữ liệu với các bảng như sau:

  • Bảng options lưu trữ các thuộc tính (ví dụ Kích cỡ, Màu sắc...) của sản phẩm và thiết lập quan hệ n-1 với bảng products (nghĩa là một sản phẩm sẽ có nhiều thuộc tính).

  • Bảng option_values lưu giữ các giá trị của từng thuộc tính (ví dụ: Kích cỡ - S/M/L, Màu sắc: Xanh/Đỏ/Vàng...), và bảng này cũng thiết lập quan hệ n-1 với bảng options. Ngoài ra thì bảng này chúng ta cũng nên thiết lập quan hệ n-1 với bảng products (mặc dù không bắt buộc, tuy nhiên nếu có sẽ dễ dàng hơn khi thực hiện truy vấn).

Với tư tưởng trên chúng ta sẽ có các bảng với biểu đồ mô tả như hình dưới:

Và chúng ta bắt đầu tiến hành tạo các model và cơ sở dữ liệu bằng lệnh sau:

php artisan make:model Option -mfs
php artisan make:model OptionValue -mfs

-mfs là tùy chọn để khởi tạo cùng lúc các file migration, factory, seeder tương ứng với model.

Với phần migration cho bảng options tại database/migrations/xxxx_xx_xx_xxxxxx_create_options_table.php chúng ta sẽ tiến hành khai báo các cột như sau:

public function up()
{
    Schema::create('options', function (Blueprint $table) {
        $table->id();
        $table->foreignIdFor(\App\Models\Product::class)->constrained()->cascadeOnDelete();
        $table->string('name');
        $table->enum('visual', ['text', 'color', 'image'])->default('text');
        $table->timestamps();
    });
}

Trong bảng này chúng ta sẽ có thêm một cột là visual để điều chỉnh cách hiển thị của từng giá trị thuộc tính, với các giá trị enum như trên thì chắc các bạn cũng đoán ra là nó có thể được hiển thị theo các hình thức nào rồi ha.

Tiếp đến phần migration cho bảng option_values chúng ta sẽ làm như sau:

public function up()
{
    Schema::create('option_values', function (Blueprint $table) {
        $table->id();
        $table->foreignIdFor(\App\Models\Product::class)->constrained()->cascadeOnDelete();
        $table->foreignIdFor(\App\Models\Option::class)->constrained()->cascadeOnDelete();
        $table->string('value');
        $table->string('label')->nullable();
        $table->timestamps();
    });
}

Cũng không có gì nhiều lắm nhỉ, và chúng ta cũng chỉ cần có vậy thôi. Ngoài ra thì xin ghi chú là cột label của chúng ta không hoàn toàn bắt buộc, mục đích của nó là để thay thế cho giá trị trong cột value khi xem trên các thiết bị chỉ hỗ trợ screen reader (ví dụ như amazon kindle) nếu phần visual của chúng ta lại là color hoặc image, bạn có thể bỏ nó đi nếu không sử dụng.

Và tiến hành chạy lệnh migrate để tạo các bảng dữ liệu:

php artisan migrate

Tiếp đến chuyển sang phần khai báo quan hệ trong các model của chúng ta tại:

app/Models/Product.php

class Product extends Model implements HasMedia
{
    ...
    
    public function options(): \Illuminate\Database\Eloquent\Relations\HasMany
    {
        return $this->hasMany(Option::class);
    }

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

app/Models/Option.php

class Option extends Model
{
    protected $fillable = ['name', 'visual'];

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

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

app/Models/OptionValue.php

class OptionValue extends Model
{
    protected $fillable = ['value', 'label'];

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

Chà! xong rồi, đúng là chả thể nào làm được gì nếu không có quan hệ nhỉ (!?).

Vì bài này tiên lượng sẽ khá dài, do đó tôi không hướng dẫn tạo factory và seeder cho từng bảng nữa mà sẽ tạo bản ghi bằng component như thao tác thông thường của người dùng luôn.

Quản lý Thuộc tính sản phẩm

Tiếp theo chúng ta sẽ bắt tay vào tạo component để quản lý Thuộc tính với livewire:make:

php artisan livewire:make Admin\\ProductOptionManager

Và trong ProductOptionManager chúng ta lại tiếp tục khai báo biến công khai $product (hơi nhàm chán nhỉ? nhưng biết sao được, không khai thì ai biết đâu mà lần :D).

class ProductOptionManager extends Component
{
    public Product $product;

    public function render()
    {
        return view('livewire.admin.product-option-manager');
    }
}

Tới phần view cho component này:

<div>
    <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">Options</h3>
            </div>
            <div class="ml-4 mt-2 flex-shrink-0">
                <x-secondary-button type="button" wire:click="create">
                    Create option
                </x-secondary-button>
            </div>
        </x-slot>
        <x-slot name="content">
            
        </x-slot>
    </x-card>
</div>

Chưa có gì ngoài mỗi nút bấm để gọi đến phương thức create trong component. Tiếp đến để tạo các thuộc tính cho sản phẩm chúng ta sẽ sử dụng đến một dialog modal, và đặt nó trong component này luôn (có thể tách nó ra thành một ProductOptionForm component như trong bài Quản lý Danh mục Sản phẩm nhé, nhưng hôm nay tôi hơi làm biếng). Nội dung của dialog modal này như sau:

<form wire:submit.prevent="save">
    <x-dialog-modal wire:model.defer="showCreateModal" max-width="lg">
        <x-slot name="title">
            Create new product option
        </x-slot>
        <x-slot name="content">
            <div class="space-y-8 divide-y divide-gray-200">
                <div>
                    <div class="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
                        <div class="sm:col-span-3">
                            <x-label for="name" value="Name" />
                            <div class="mt-1">
                                <x-input wire:model.defer="option.name" type="text" name="name" id="name" class="max-w-lg block w-full sm:max-w-xs sm:text-sm" placeholder="Eg: Size, Color" />
                                <x-input-error for="option.name" class="mt-2" />
                            </div>
                        </div>

                        <div class="sm:col-span-3">
                            <x-label for="visual" value="Visual" />
                            <div class="mt-1">
                                <x-select wire:model.defer="option.visual" name="visual" id="visual" class="max-w-lg block w-full sm:max-w-xs sm:text-sm">
                                    <option value="">Please select</option>
                                    <option value="text">Text</option>
                                    <option value="color">Color</option>
                                    <option value="image">Image</option>
                                </x-select>
                                <x-input-error for="option.visual" class="mt-2" />
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </x-slot>
        <x-slot name="footer">
            <div>
                <x-secondary-button wire:click="$set('showCreateModal', false)">
                    Cancel
                </x-secondary-button>
                <x-primary-button>
                    Save
                </x-primary-button>
            </div>
        </x-slot>
    </x-dialog-modal>
</form>

Và tiến hành nhúng nó vào ProductManager component như sau:

<div class="col-span-3 xl:col-span-2">
    ...
    
    <livewire:admin.product-option-manager :product="$product" />
</div>

Quay trở lại với ProductOptionManager component chúng ta tiến hành khai báo biến boolean tên là $showCreateModal với giá trị mặc định là false và với phương thức create thì chúng ta sẽ set cho nó thành true như sau:

use App\Models\Option;

class ProductOptionManager extends Component
{
    ...

    public Option $option;
    public bool $showCreateModal = false;

    public function create()
    {
        $this->option = new Option();
        $this->showCreateModal = true;
    }

    ...
}

Ok, lúc này bạn nhấn vào nút create thì dialog modal của chúng ta sẽ xuất hiện ra nhé. Tất nhiên là với Livewire thì chúng ta có thể sử dụng $set('showCreateModal', true) để gọi modal trên, nhưng ở phần sau chúng ta còn cần đến nhiều thứ khác nên tôi sử dụng một phương thức riêng cho nó!

Tiếp đến để modal của chúng ta có thể hoạt động, có nghĩa là có thể submit được form thì cần phải khai báo $rules cho các trường input và một phương thức save để lưu lại nội dung của form này. Tại ProductOptionManager chúng ta làm như sau:

class ProductOptionManager extends Component
{
    ...

    protected $rules = [
        'option.name' => 'required|string',
        'option.visual' => 'required|string',
    ];

    public function save()
    {
        $this->validate();
        $this->product->options()->save($this->option);
    }

    ...
}

Đến đây thì bạn có thể submit modal của chúng ta rồi, tuy nhiên hãy cố thêm một chút là in các thuộc tính của sản phẩm mà chúng ta sẽ tạo ra view của ProductOptionManager component đã nào:

<x-slot name="content">
    <div class="space-y-5">
        @foreach($product->options as $option)
        <div class="relative w-full border border-gray-300 rounded-md p-4">
            <div class="absolute -top-3 left-3 px-0.5 bg-white flex items-center space-x-1">
                <button wire:click="confirmOptionDeletionFor('{{ $option->id }}')">
                    <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-red-400 hover:text-red-500 cursor-pointer" viewBox="0 0 20 20" fill="currentColor">
                        <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
                    </svg>
                </button>
                <span class="font-medium text-sm text-gray-700 flex items-center">{{ $option->name }}</span>
            </div>
        </div>
        @endforeach
    </div>
</x-slot>

Và submit form đi nào, xem kết quả có giống như của tôi không?
 

Bạn có để ý là ngay kế bên tên của thuộc tính có nút bấm với biểu tượng thùng rác không? Đúng rồi nó sẽ trỏ đến phương thức delete để thực hiện xóa đi các thuộc tính đã tạo. Quay lại component ProductOptionManager làm tiếp nào:

class ProductOptionManager extends Component
{
    ...

    public Option $optionBeingDeleted;
    public bool $confirmingOptionDeletion = false;

    public function confirmOptionDeletionFor(Option $option)
    {
        $this->confirmingOptionDeletion = true;
        $this->optionBeingDeleted = $option;
    }

    public function delete()
    {
        $this->optionBeingDeleted->optionValues()->delete();
        $this->optionBeingDeleted->delete();
    }

    ...
}

Vì nút bấm thùng rác của chúng ta không trực tiếp gọi đến phương thức delete() mà là confirmOptionDeletionFor() để hiện ra một confirmation modal nhằm xác nhận hành động trước khi thực hiện xóa bỏ dữ liệu nên tôi làm như trên nhé! Nó cũng giống cách xóa bỏ Danh mục Sản phẩm mà tôi đã hướng dẫn trước đây rồi nên không viết lại nữa.

Ngoài ra thì trước khi thực hiện xóa thuộc tính đã chọn tôi gọi đến phương thức xóa các giá trị của nó ở bảng option_values luôn, vì hiện tại chúng ta chưa có giá trị nào nên việc xóa bỏ sẽ không gặp khó khăn gì nhưng chút nữa làm đến phần giá trị thuộc tính thì sẽ bị vướng khóa ngoại dẫn đến không thể xóa bỏ các thuộc tính nếu đã tồn tại giá trị đi kèm, lưu ý nhé!

Quản lý Giá trị cho từng Thuộc tính

Tiếp nào, xong phần thuộc tính rồi đến phần các giá trị cho chúng. Ở đây tôi sẽ tạo thêm một component mới là ProductOptionValueManager với livewire:make:

php artisan livewire:make Admin\\ProductOptionValueManager

Và tiến hành khai báo các biến công khai sẽ sử dụng, ngoài ra thì chúng ta sẽ có một form để tạo nhanh các giá trị cho thuộc tính nên tôi khai báo đồng thời $rules và phương thức save() luôn như sau:

<?php

namespace App\Http\Livewire\Admin;

class ProductOptionValueManager extends Component
{
    public Product $product;
    public Option $option;
    public OptionValue $optionValue;

    protected $rules = [
        'optionValue.product_id' => 'required',
        'optionValue.option_id' => 'required',
        'optionValue.value' => ['required', Rule::unique('option_values', 'value')->where('option_id', $this->option->id)],
        'optionValue.label' => 'nullable',
    ]

    public function mount()
    {
        $this->optionValue = new OptionValue([
            'product_id' => $this->product->id,
            'option_id' => $this->option->id,
        ]);
    }

    public function save()
    {
        $this->validate();
        $this->optionValue->save();
    }
    
    public function render()
    {
        return view('livewire.admin.product-option-value-manager');
    }
}

Lưu ý trong phương thức mount() chúng ta tiến hành khởi tạo OptionValue model instance và gán nó với biến $optionValue trong đó sẽ có sẵn hai khóa ngoại là product_id và option_id. Và lúc này bạn sẽ bị báo lỗi mass assignment với hai trường này, hãy nhập chúng vào mảng $fillable trong OptionValue model để được chấp nhận!

Phần view của component này chúng ta sẽ in ra danh sách các giá trị đã tạo và có một form để tạo các giá trị mới như sau:

<div>
    <div class="mb-3">
        @forelse($option->optionValues as $optionValue)
            <div class="relative inline-flex border border-gray-300 rounded px-3 py-2 group">
                <button type="button" wire:click="confirmOptionValueDeletionFor('{{ $optionValue->id }}')" class="absolute inline-flex items-center justify-center top-0 right-0 w-3 h-3 rounded-full ring-1 ring-white bg-red-600 hover:bg-red-400 text-white opacity-0 group-hover:opacity-100 transition-opacity">
                    <svg xmlns="http://www.w3.org/2000/svg" class="h-2.5 w-2.5" viewBox="0 0 20 20" fill="currentColor">
                        <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
                    </svg>
                </button>
                <span class="text-sm font-sans font-medium text-gray-400">
                    {{ $optionValue->label ?? $optionValue->value }}
                </span>
            </div>
        @empty
            <p class="text-sm text-gray-600">This option is ready to be used but has no value yet!</p>
        @endforelse
    </div>
    
    <form wire:submit.prevent="save">
        <div class="space-y-6 lg:flex lg:space-x-6 lg:space-y-0">
            <div class="lg:flex-1">
                <x-label value="{{ __('Value') }}" />
                <x-input wire:model.defer="optionValue.value" type="text" class="mt-1 block w-full sm:text-sm" placeholder="{{ __('Option value for ') . $option->name }}" />
                <x-input-error for="optionValue.value" class="mt-2" />
            </div>
            <div class="lg:flex-1">
                <x-label value="{{ __('Label') }}" />
                <x-input wire:model.defer="optionValue.label" type="text" class="mt-1 block w-full sm:text-sm" placeholder="{{ __('Option label for ') . $option->name }}" />
                <x-input-error for="optionValue.label" class="mt-2" />
            </div>
            <div class="lg:flex lg:items-end">
                <x-primary-button>
                    {{ __('Save') }}
                </x-primary-button>
            </div>
        </div>
    </form>
</div>

Ok rồi, lần này sẽ khác một chút là phần view này chúng ta sẽ không nhúng vào ProductManager mà nhúng trong ProductOptionManager.

<div class="space-y-5">
    @foreach($product->options as $option)
        <div class="relative w-full border border-gray-300 rounded-md p-4">
            <div class="absolute -top-3 left-3 px-0.5 bg-white flex items-center space-x-1">
                ...
                
                <span class="font-medium text-sm text-gray-700 flex items-center">{{ $option->name }}</span>
            </div>
            <livewire:admin.product-option-value-manager :product="$product" :option="$option" :key="$option->id" />
        </div>
    @endforeach
</div>

Lưu ý là chúng ta đang ở trong vòng lặp nên phải có :key để Livewire phân biệt được các view lặp lại này, nếu không sẽ phát sinh lỗi.

Bây giờ hãy reload lại trang thông tin sản phẩm của chúng ta và thử tạo một giá trị cho thuộc tính mà bạn vừa tạo xem thế nào, nếu giống như của tôi dưới đây là ngon rồi đấy!
 

Vậy là xong phần Thuộc tính và các giá trị của chúng, tiếp đến là phần dành cho Biến thể.

Tạo và quản lý Biến thể sản phẩm.

Như đã nói ở trên một biến thể (variant) là sự kết hợp của các giá trị theo từng thuộc tính. Các biến thể được tính toán theo cách nhân tổng số lượng giá trị của từng thuộc tính với nhau, lấy ví dụ như ở hình trên tôi đã tạo 2 thuộc tính với 3 giá trị cho mỗi thuộc tính chúng ta sẽ có 3x3 = 9 biến thể. Nếu tôi tạo thêm 1 thuộc tính mới với 4 giá trị thì số lượng sẽ tăng lên là 3x3x4 = 36 biến thể.

Theo như tìm hiểu của tôi thì với một số nền tảng bán hàng lớn hiện nay như Shopify. Haravan, Sapo... đều giới hạn số lượng thuộc tính nhất định là 3 thuộc tính (không giới hạn giá trị của thuộc tính). Nếu giới hạn như vậy chúng ta chỉ cần tạo 1 bảng variants trong đó có chứa các cột như sau:

  1. option_one_id, option_value_one_id

  2. option_two_id, option_value_two_id

  3. option_three_id option_value_three_id

Và với số lượng biến thể sau khi được tính toán chúng ta chỉ cần tạo một số lượng các bản ghi variant tương ứng kèm theo id của các thuộc tính và giá trị của chúng là xong. Tuy nhiên theo cách làm của tôi thì chúng ta sẽ không giới hạn số lượng thuộc tính, hay nói cách khác là vô hạn, mặc dù rằng các nền tảng lớn họ đã có sự nghiên cứu kỹ lưỡng trước khi ràng buộc số lượng như trên rồi.

Với cách làm của chúng ta trong dự án này sẽ cần tạo 2 bảng phục vụ cho việc quản lý biến thể của sản phẩm trong đó:

  1. Bảng skus sẽ lưu giữ các giá trị của một biến thể bao gồm mã SKU, giá bán, tồn kho...

  2. Bảng variants sẽ chỉ có các khóa ngoại tới skusoptions và option_values.

Tôi sẽ sử dụng biểu đồ để mô tả quan hệ của các bảng dữ liệu của chúng ta như hình dưới đây:
 

Ok và chúng ta tiến hành xây dựng phần này, bắt đầu với các models và migrations với lệnh make:model:

php artisan make:model SKU -mfs
php artisan make:model Variant -mfs

Lưu ý: chúng ta sử dụng model SKU với 3 chữ cái in hoa, trong trường hợp này Laravel sẽ tự ngầm hiểu là bảng dữ liệu của chúng ta sẽ được đặt tên là s_k_u_s nhưng tôi thì không muốn vậy nên tôi khai báo trong model SKU tên bảng là skus như sau:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class SKU extends Model
{
    use HasFactory;

    protected $table = 'skus';
}

Ngoài ra thì tên file migrate của bảng skus cũng có dạng xxxx_xx_xx_xxxxxx_create_s_k_u_s_table.php nên tôi sẽ đổi lại là xxxx_xx_xx_xxxxxx_create_skus_table.php (không có dấu gạch dưới).

Và trong nội dung của file này chúng ta cũng tiến hành đổi lại tên bảng cùng với các cột dữ liệu như sau:

public function up()
{
    Schema::create('skus', function (Blueprint $table) {
        $table->id();
        $table->foreignIdFor(\App\Models\Product::class)->constrained()->cascadeOnDelete();
        $table->string('name')->unique()->nullable();
        $table->string('barcode')->unique()->nullable();
        $table->decimal('price', 12)->default(0);
        $table->integer('stock')->default(0);
        $table->timestamps();
    });
}

Vậy là xong, tiếp đến file migrate của bảng variants chúng ta làm như sau:

public function up()
{
    Schema::create('variants', function (Blueprint $table) {
        $table->id();
        $table->foreignIdFor(\App\Models\Product::class)->constrained();
        $table->foreignIdFor(\App\Models\SKU::class, 'sku_id')->constrained();
        $table->foreignIdFor(\App\Models\Option::class)->constrained();
        $table->foreignIdFor(\App\Models\OptionValue::class)->constrained();
        $table->timestamps();
    });
}

Bạn có thấy trong phần khai báo khóa ngoại cho model SKU chúng ta phải khai thêm phần tên cột là sku_id không? Nếu không làm vậy thì Laravel sẽ tự tạo tên cho nó là s_k_u_id (không biết bạn thấy sao chứ tôi thì thấy khó chịu khi bị cách ra như vậy nên tôi phải đổi).

Chạy lệnh migrate để hoàn tất quy trình tạo bảng:

php artisan migrate

Xong xuôi, quay trở lại với Product, SKU và Variant model để khai báo quan hệ:

app/Models/Product.php

class Product extends Model implements HasMedia
{
    public function skus(): \Illuminate\Database\Eloquent\Relations\HasMany
    {
        return $this->hasMany(SKU::class);
    }

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

app/Models/SKU.php

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

    public function variants(): \Illuminate\Database\Eloquent\Relations\HasMany
    {
        return $this->hasMany(Variant::class, 'sku_id');
    }
}

app/Models/Variant.php

class Variant extends Model
{   
    public function option(): \Illuminate\Database\Eloquent\Relations\BelongsTo
    {
        return $this->belongsTo(Option::class);
    }

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

Rồi nhé, tiếp theo chúng ta sẽ bắt tay vào tạo component để quản lý biến thể cho sản phẩm. Tuy nhiên trước khi tiến hành tôi sẽ giải thích về cách tạo các biến thể để giúp bạn nắm được quy trình. Các biến thể của sản phẩm sẽ được tạo ra một cách hoàn toàn tự động (auto generated) ngay sau khi một thuộc tính hoặc giá trị của thuộc tính được thêm hoặc bớt, theo đó sẽ có hai trường hợp xảy ra:

  1. Khi thêm hoặc bớt thuộc tính các biến thể sẽ được tạo mới lại toàn bộ từ đầu, tại sao lại như vậy? Hãy tưởng tượng khi chúng ta đang có 2 thuộc tính là Kích thước và Màu sắc thì biến thể của chúng sẽ là (Kích thước * Màu sắc). Tuy nhiên nếu chúng ta thêm một thuộc tính mới là Chất liệu, lúc này biến thể sẽ có dạng (Kích thước * Màu sắc * Chất liệu). Đó là lý do chúng ta phải tạo mới lại toàn bộ các biến thể.

  2. Ở trường hợp thứ hai, khi chúng ta chỉ thêm hoặc bớt các giá trị của thuộc tính thì sẽ không cần phải tạo mới toàn bộ các biến thể mà ở đây chúng ta chỉ thực hiện tính toán các biến thể cần tạo giữa giá trị mới được thêm với giá trị của các thuộc tính còn lại mà thôi. Có thể bạn sẽ nghĩ rằng: hmm, thế sao không tạo mới từ đầu đi? Tôi sẽ giải thích như sau: Việc chỉ tạo mới các biến thể còn sót sẽ giúp cho dữ liệu của các biến thể đã tồn tại không bị tổn hại, hay nói cách khác là không phải nhập liệu lại từ đầu.

Với quy trình trên chúng ta sẽ không cần đến một thao tác nào trong Variants component nữa, thay vào đó sẽ là các trường input để người dùng có thể cập nhật thông tin cho từng biến thể mà thôi. Hình dạng của nó sẽ như này:
 

Tiến hành tạo component ProductVariantManager với livewire:make:

php artisan livewire:make Admin\\ProductVariantManager

Trong ProductVariantManager component chúng ta tiếp tục khai báo biến công khai là $product và một biến khác là $skus để nhận danh sách các skus của product được truyền vào từ view. Ngoài ra thì component này sẽ có nhiệm vụ thực hiện cập nhật thông tin cho từng sku nên chúng ta tiến hành khai báo $rules và một phương thức save để lưu lại thông tin như sau:

class ProductVariantManager extends Component
{
    public Product $product;
    public Collection $skus;

    protected $rules = [
        'skus.*.name' => 'nullable|string',
        'skus.*.barcode' => 'nullable|string',
        'skus.*.price' => 'required|numeric',
        'skus.*.stock' => 'required|numeric',
    ];

    public function save()
    {
        $this->validate();

        foreach ($this->skus as $index => $sku) {
            $this->validate([
                "skus.$index.name" => ['nullable', 'string', Rule::unique('skus', 'name')->ignoreModel($sku)],
                "skus.$index.barcode" => ['nullable', 'string', Rule::unique('skus', 'barcode')->ignoreModel($sku)]
            ], [
                "skus.$index.name.unique" => 'Duplicated.'
            ]);
            
            $sku->save();
        }
    }

    public function render()
    {
        return view('livewire.admin.product-variant-manager');
    }
}

Như các bạn có thể thấy tôi thực hiện validate hai lần lý do là vì cột name và barcode của chúng ta là duy nhất (unique) và với global rules thì chúng ta không thể biết được model hay id của sku nào đang thực hiện cập nhật do đó chúng ta phải thực hiện thêm 1 bước validate trong vòng lặp, trước khi lưu lại giá trị của sku.

Tiếp đến phần view của component này chúng ta sẽ có:

<div>
    <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">Variants</h3>
            </div>
        </x-slot>
        <x-slot name="content">
            <div class="space-y-6 -mx-4 -mt-6 sm:-mx-6">
                <div x-data class="relative max-h-96 overflow-auto">
                    <table class="min-w-full">
                        <thead>
                            <tr class="relative">
                                <th class="pl-6 pr-2 py-3 sticky top-0 z-10 border-b bg-gray-50 bg-opacity-75 backdrop-blur backdrop-filter text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                                    Variant
                                </th>
                                <th class="px-2 py-3 sticky top-0 z-10 border-b bg-gray-50 bg-opacity-75 backdrop-blur backdrop-filter text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                                    Price
                                </th>
                                <th class="px-2 py-3 sticky top-0 z-10 border-b bg-gray-50 bg-opacity-75 backdrop-blur backdrop-filter text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                                    Stock
                                </th>
                                <th class="pl-2 pr-6 py-3 sticky top-0 z-10 border-b bg-gray-50 bg-opacity-75 backdrop-blur backdrop-filter text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                                    SKU
                                </th>
                                <th class="pl-2 pr-6 py-3 sticky top-0 z-10 border-b bg-gray-50 bg-opacity-75 backdrop-blur backdrop-filter text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                                    Barcode
                                </th>
                            </tr>
                        </thead>
                        <tbody class="bg-white">
                            <form wire:submit.prevent="save">
                                @foreach($product->skus as $index => $sku)
                                    <tr wire:key="sku-field-{{ $sku->id }}">
                                        <td class="pl-6 pr-2 py-4 border-b border-gray-200 whitespace-nowrap text-sm text-gray-900 font-medium">
                                            <div class="flex">
                                                @foreach($sku->variants as $variant)
                                                    <p @class(['ml-2 pl-2 border-l border-gray-200' => !$loop->first])>
                                                        {{ $variant->optionValue->label ?? $variant->optionValue->value }}
                                                    </p>
                                                @endforeach
                                            </div>
                                        </td>
                                        <td class="px-2 py-4 border-b border-gray-200 whitespace-nowrap text-sm text-gray-500">
                                            <x-label for="sku-{{ $index }}-price" value="Price" class="sr-only" />
                                            <div class="relative">
                                                <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
                                                    <span class="text-gray-500 sm:text-sm">
                                                        {{ config('money.'.config('app.currency').'.symbol') }}
                                                    </span>
                                                </div>
                                                <x-input wire:model.defer="skus.{{ $index }}.price" wire:change.debounce="save" @click="$event.target.select();" @keyup.enter="$event.target.blur();" type="text" id="sku-{{ $index }}-price" class="block w-28 sm:text-sm shadow-none pl-7" />
                                            </div>
                                            <x-input-error for="skus.{{ $index }}.price" class="mt-2" />
                                        </td>
                                        <td class="px-2 py-4 border-b border-gray-200 whitespace-nowrap text-sm text-gray-500">
                                            <x-label for="sku-{{ $index }}-stock" value="Price" class="sr-only" />
                                            <x-input wire:model.defer="skus.{{ $index }}.stock" wire:change.debounce="save" @click="$event.target.select();" @keyup.enter="$event.target.blur();" type="number" id="sku-{{ $index }}-stock" class="block w-28 sm:text-sm shadow-none" />
                                            <x-input-error for="skus.{{ $index }}.stock" class="mt-2" />
                                        </td>
                                        <td class="pl-2 pr-6 py-4 border-b border-gray-200 whitespace-nowrap text-sm font-medium text-gray-900">
                                            <x-label for="sku-{{ $index }}-name" value="Name" class="sr-only" />
                                            <x-input wire:model.defer="skus.{{ $index }}.name" wire:change.debounce="save" @click="$event.target.select();" @keyup.enter="$event.target.blur();" type="text" id="sku-{{ $index }}-name" class="block w-40 sm:text-sm shadow-none" />
                                            <x-input-error for="skus.{{ $index }}.name" class="mt-2" />
                                        </td>
                                        <td class="pl-2 pr-6 py-4 border-b border-gray-200 whitespace-nowrap text-sm font-medium text-gray-900">
                                            <x-label for="sku-{{ $index }}-barcode" value="Barcode" class="sr-only" />
                                            <x-input wire:model.defer="skus.{{ $index }}.barcode" wire:change.debounce="save" @click="$event.target.select();" @keyup.enter="$event.target.blur();" type="text" id="sku-{{ $index }}-barcode" class="block w-40 sm:text-sm shadow-none" />
                                            <x-input-error for="skus.{{ $index }}.barcode" class="mt-2" />
                                        </td>
                                    </tr>
                                @endforeach
                            </form>
                        </tbody>
                    </table>
                </div>
            </div>
        </x-slot>
    </x-card>
</div>

Dài nhỉ? Thật sự tôi rất ngại khi phải đăng một đoạn code dài như này làm chúng ta phải cuộn chuột muốn rụng cả ngón tay, nhưng cũng không có cách nào khác cả các bạn ạ! Chịu khó tí nhé!

Như các bạn có thể thấy thì ở phần view này sẽ có các trường input cho từng giá trị của các sku tương ứng. Các trường input này sẽ tự động được gửi đi ngay sau khi chúng ta hoàn thành việc nhập dữ liệu với wire:change.

Và tiến hành nhúng nó vào ProductManager component:

<div class="col-span-3 xl:col-span-2">
    ...

    <livewire:admin.product-option-manager :product="$product" />
    
    <livewire:admin.product-variant-manager :product="$product" :skus="$product->skus" />
</div>

Ok, bây giờ reload lại trang sản phẩm chúng ta sẽ có một bảng mới dành cho phần variant và dĩ nhiên chưa có nội dung gì cả. Đúng rồi, vì chúng ta chưa có phương thức để tự động tạo ra các biến thể sau khi thêm hoặc bớt thuộc tính cũng như giá trị của thuộc tính. Làm tiếp nào!

Tại model Product chúng ta thêm một phương thức để thực hiện tạo ra các biến thể dựa trên dữ liệu nhập vào là một mảng có chứa các giá trị của từng thuộc tính như sau:

public function generateVariant(array $input): array
{
    if (! count($input)) return [];

    $result = [[]];

    foreach ($input as $key => $values) {
        $append = [];
        foreach ($values as $value) {
            foreach ($result as $data) {
                $append[] = $data + [$key => $value];
            }
        }
        $result = $append;
    }

    return $result;
}

Và thêm một phương thức để thêm các bản ghi của từng variant vào cơ sở dữ liệu:

public function saveVariant(array $variants)
{
    $skus = $this->skus()->createMany(array_fill(0, count($variants), []));

    $variantOptions = [];

    foreach ($skus as $index => $sku) {
        foreach ($variants[$index] as $optionValue) {
            $variantOptions[] = [
                'product_id' => $this->id,
                'sku_id' => $sku->id,
                'option_id' => $optionValue['option_id'],
                'option_value_id' => $optionValue['id'],
                'created_at' => now(),
                'updated_at' => now(),
            ];
        }
    }

    $this->variants()->insert($variantOptions);
}

Bây giờ quay trở lại với phương thức save trong ProductOptionManager, ngay sau khi tạo mới một thuộc tính chúng ta tiến hành tính toán và lưu lại các biến thể như sau:

public function save()
{
    $this->validate();
    $this->product->options()->save($this->option);
    // remove all existing skus
    $this->product->skus()->delete();
    // generate and save variant
    $optionValues = $this->product->optionValues->groupBy('option_id')->values()->toArray();
    $variants = $this->product->generateVariant($optionValues);
    $this->product->saveVariant($variants);
}

Tương tự với ProductOptionValueManager, ngay sau khi thêm một giá trị mới chúng ta cũng tiến hành tính toán và lưu lại các bản ghi của biến thể. Chỉ có điều khác là thay vì tính toán toàn bộ các giá trị thì ở đây chỉ tính toán giữa giá trị mới thêm và giá trị của từng thuộc tính còn lại.

public function save()
{
    $this->validate();
    $this->optionValue->save();

    $optionValues = [];

    if ($this->product->optionValues->count() > 1) {
        $previousOptionValues = $this->product->optionValues
            ->whereNotIn('option_id', $this->optionValue->option_id)
            ->groupBy('option_id')
            ->values()
            ->toArray();
        $optionValues = array_merge($previousOptionValues, [[$this->optionValue->toArray()]]);
    } else {
        $optionValues[] = [$this->optionValue->toArray()];
    }

    // generate and save variant
    $variants = $this->product->generateVariant($optionValues);
    $this->product->saveVariant($variants);
}

Ok rồi, bây giờ hãy thử tạo các thuộc tính và thêm giá trị cho chúng xem sao nhé! Và có lẽ bài này của chúng ta có thể kết thúc ở đây được rồi.

Kết luận

Bài này rất dài! Phải nói đây là bài dài nhất trong loạt bài hướng dẫn này, thay vì một buổi đối với các bài khác thì bài này tốn của tôi 2 ngày để hoàn thành. Đổi lại thì kết quả hoàn toàn xứng đáng đúng không nảo? Nếu có bất kỳ thắc mắc nào hãy để lại bình luận ở phía dưới, tôi sẽ cố gắng giải đáp nếu có thể!

Sau bài này sẽ là một bài dễ hơn chút đó là tổ chức liên kết sản phẩm với các danh mục, hẹn gặp lại các bạn!