0

# 03 — Mô hình nghiệp vụ (Domain Model) Phần 1

Tài liệu #2. Tham chiếu: 01-master-plan.md — mục 5 (bounded context), mục 6 (vòng đời đơn hàng), mục 7 (hexagonal), mục 8 (tầng dữ liệu). Ngày lập: 13/08/2026 · Trạng thái: Bản nháp 1 — chờ chốt Phạm vi: tầng Domain và Application. Không nói về hạ tầng, không nói về SQL vật lý (xem 03-database-schema.md).


0. Cách đọc tài liệu này

Tài liệu này là hợp đồng nghiệp vụ của dự án. Khi code và tài liệu này mâu thuẫn, một trong hai sai — phải sửa, không được để trôi.

Mỗi bounded context được mô tả theo đúng một khuôn mẫu:

Mục Nghĩa
Loại Core / Supporting / Generic — quyết định mức đầu tư công sức
Chịu trách nhiệm Những câu hỏi mà context này là nơi duy nhất trả lời được
KHÔNG chịu trách nhiệm Ranh giới — phần hay bị lấn nhất
Aggregate Đơn vị nhất quán giao dịch. Một transaction = một aggregate
Value Object Kiểu dữ liệu bất biến, tự kiểm tra tính hợp lệ
Bất biến (Invariant) Điều luôn đúng. Vi phạm = ném exception, không bao giờ lưu
Lệnh (Command) Ý định thay đổi trạng thái
Sự kiện phát ra Sự thật đã xảy ra, thì quá khứ, bất biến
Sự kiện lắng nghe Phản ứng với context khác
Port Interface ra thế giới bên ngoài (Domain định nghĩa, Infrastructure hiện thực)

Quy ước ký hiệu:

  • = bất biến nghiêm ngặt, phải có test riêng
  • = chỗ dễ mô hình hoá sai, đã có người trả giá
  • = hoãn đến pha sau, ghi ở đây để không quên

1. Nguyên tắc mô hình hoá

Bảy quy tắc dưới đây được áp dụng không ngoại lệ. Chúng là lý do mô hình này không rã ra sau 6 tháng.

1.1 Một transaction = một aggregate

Không bao giờ sửa hai aggregate trong cùng một transaction cơ sở dữ liệu. Nếu cần, dùng domain event + outbox (doc 01 §8.8) để aggregate thứ hai được cập nhật ở transaction sau, chấp nhận nhất quán cuối (eventual consistency).

Hệ quả thực tế: OrderStockLevel không được cập nhật trong cùng một transaction. Quy trình đúng: giữ chỗ tồn trước (transaction 1) → tạo đơn (transaction 2) → nếu tạo đơn thất bại thì nhả chỗ (bù trừ).

⚠ Đây là quy tắc bị vi phạm nhiều nhất vì "chỉ thêm một dòng $stock->save() thôi mà". Chính một dòng đó biến deadlock thành chuyện thường ngày khi tải lên.

1.2 Aggregate nhỏ nhất có thể

Aggregate là ranh giới khoá, không phải ranh giới hiển thị. Aggregate to = tranh chấp khoá cao = thông lượng thấp.

Cám dỗ sai Đúng
Product chứa tất cả Variant và tồn kho của chúng Product chứa Variant (ít, đổi cùng nhau). Tồn kho là aggregate riêng (StockItem), vì nó đổi hàng nghìn lần trong khi product đứng yên
Customer chứa toàn bộ Order Order là aggregate riêng. Customer chỉ giữ CustomerId
Order chứa ShipmentPayment Ba aggregate riêng, nối bằng OrderId và domain event

1.3 Tham chiếu chéo aggregate bằng ID, không bằng object

// ✗ SAI — kéo cả aggregate khác vào, mở cửa cho sửa xuyên ranh giới
final class Order { private Customer $customer; }

// ✓ ĐÚNG
final class Order { private CustomerId $customerId; }

1.4 Bất biến được cưỡng chế trong constructor và phương thức, không ở tầng validation

Validation HTTP chặn dữ liệu rác. Bất biến nghiệp vụ chặn trạng thái vô nghĩa. Hai thứ khác nhau, cả hai đều cần.

// Tầng Presentation: FormRequest kiểm tra "quantity là số nguyên dương"
// Tầng Domain:      Order kiểm tra "không thêm dòng vào đơn đã shipped"

Một aggregate được nạp từ CSDL rồi gọi phương thức nghiệp vụ phải không thể rơi vào trạng thái sai, kể cả khi tầng HTTP bị bỏ qua (job, console, seeder, test).

1.5 Domain layer không biết Laravel

Không Illuminate\*, không Eloquent, không now(), không config(), không auth(). Thời gian và định danh người dùng được truyền vào như tham số.

// ✗ SAI: $this->paidAt = now();
// ✓ ĐÚNG: public function markPaid(\DateTimeImmutable $at, UserId $by): void

Lý do không phải là "thuần khiết cho đẹp": nó khiến mọi quy tắc nghiệp vụ test được không cần database, không cần thời gian thật, và khiến test chạy trong mili-giây thay vì giây.

1.6 Sự kiện là sự thật quá khứ, lệnh là ý định

Lệnh (Command) Sự kiện (Event)
Thì Mệnh lệnh: PlaceOrder Quá khứ: OrderPlaced
Có thể từ chối? Không — đã xảy ra rồi
Số người nhận Đúng 1 handler 0..n listener
Chứa gì Ý định + dữ liệu đầu vào Sự thật đã chốt + đủ ngữ cảnh cho người nghe

⚠ Sự kiện không được chứa tham chiếu tới aggregate. Nó chứa dữ liệu đã sao chép. Người nghe không được phép "đi nạp lại object" — trạng thái có thể đã đổi từ lúc sự kiện phát ra.

1.7 Chính sách (Policy) tách khỏi Aggregate

Quy tắc hay đổi (ai được giảm giá bao nhiêu, đơn nào bị coi là rủi ro, khi nào miễn phí ship) nằm trong Policy — object thuần, thay được, test riêng. Aggregate chỉ giữ bất biến không đổi (đơn đã giao không thể quay về nháp).


2. Phân loại context theo mức đầu tư

Không phải context nào cũng xứng đáng với cùng một lượng công sức. Đây là bản đồ đầu tư.

Loại Nghĩa Cách làm Context
Core Nơi tạo lợi thế cạnh tranh và nơi mất tiền nếu sai Hexagonal đầy đủ, coverage ≥ 90%, mutation test, ADR cho mọi quyết định Inventory, Order, Payment, Pricing, Promotion, Checkout
Supporting Riêng của mình nhưng không tạo lợi thế Hexagonal nhẹ: có Domain layer, Repository có thể trả Eloquent model Catalog, Customer, Fulfilment, Returns, Loyalty, Procurement, Tax & Invoice
Generic Ai cũng làm giống nhau, mua/dùng lại được Laravel thuần + package có sẵn. Ép hexagonal ở đây là lãng phí IAM, CMS, Notification, Review, Search, Analytics

Quy tắc ngân sách: nếu bạn đang viết Value Object cho CMS, bạn đang tiêu công sức sai chỗ. Nếu bạn đang dùng $order->status = 'paid' trong Order, bạn đang tiết kiệm sai chỗ.


3. Value Object dùng chung (Shared Kernel)

Đây là toàn bộ nội dung được phép nằm trong modules/Shared — thêm bất cứ thứ gì khác vào đây là bắt đầu tạo ra một monolith trá hình.

3.1 Danh mục

Value Object Bất biến tự kiểm Ghi chú
Money currency hợp lệ ISO-4217; phép cộng chỉ giữa cùng tiền tệ Bọc brick/money, không tự tính bằng float
Quantity > 0 với dòng hàng; >= 0 với tồn kho Hai kiểu riêng: PositiveQuantity, StockQuantity
Sku 3–64 ký tự, [A-Z0-9._-], tự chuẩn hoá hoa Định danh nghiệp vụ của Variant
VariantId, ProductId, OrderId, CustomerId UUIDv7 hợp lệ Kiểu riêng cho từng loại — chặn truyền nhầm ID
EmailAddress RFC + chuẩn hoá thường
PhoneNumberVn Chuẩn hoá về +84…, chấp nhận 0… đầu vào ⚠ Quan trọng: khách nhập 0987…, cổng SMS/ZNS cần +84987…
AddressSnapshot Bất biến hoàn toàn, chứa văn bản đầy đủ Xem §3.3
Percentage 0 <= v <= 100, numeric(5,4)
DateRange from < to Dùng cho bảng giá, khuyến mãi
TaxRate 0 <= v <= 1, kèm valid_from Xem §14
Slug thường, [a-z0-9-], đã bỏ dấu tiếng Việt

3.2 Money — mẫu chuẩn cho mọi Value Object

Đây là mẫu thể hiện cách dùng PHP 8.5 trong tầng Domain. Mọi VO khác viết theo cùng phong cách.

<?php
declare(strict_types=1);

namespace Modules\Shared\Domain;

/**
 * Số tiền bất biến. KHÔNG dùng float ở bất kỳ đâu.
 * Đơn vị lưu trữ: numeric(19,4) trong PostgreSQL (doc 01 §8.3).
 */
final readonly class Money implements \JsonSerializable
{
    private function __construct(
        public string $amount,      // chuỗi thập phân chính xác, vd "159000.0000"
        public string $currency,    // ISO-4217, vd "VND"
    ) {}

    public static function of(string|int $amount, string $currency = 'VND'): self
    {
        $currency = strtoupper($currency);

        if (!preg_match('/^[A-Z]{3}$/', $currency)) {
            throw new \InvalidArgumentException("Mã tiền tệ không hợp lệ: {$currency}");
        }
        if (!preg_match('/^-?\d+(\.\d{1,4})?$/', (string) $amount)) {
            throw new \InvalidArgumentException("Số tiền không hợp lệ: {$amount}");
        }

        return new self(number_format((float) $amount, 4, '.', ''), $currency);
    }

    public static function zero(string $currency = 'VND'): self
    {
        return new self('0.0000', $currency);
    }

    public function plus(self $other): self
    {
        $this->assertSameCurrency($other);
        // clone with (PHP 8.5) — hợp lệ vì gọi TỪ TRONG class scope
        return clone($this, ['amount' => bcadd($this->amount, $other->amount, 4)]);
    }

    public function minus(self $other): self
    {
        $this->assertSameCurrency($other);
        return clone($this, ['amount' => bcsub($this->amount, $other->amount, 4)]);
    }

    public function multipliedBy(int $factor): self
    {
        return clone($this, ['amount' => bcmul($this->amount, (string) $factor, 4)]);
    }

    public function isNegative(): bool  { return bccomp($this->amount, '0', 4) < 0; }
    public function isZero(): bool      { return bccomp($this->amount, '0', 4) === 0; }

    public function isGreaterThan(self $other): bool
    {
        $this->assertSameCurrency($other);
        return bccomp($this->amount, $other->amount, 4) > 0;
    }

    public function equals(self $other): bool
    {
        return $this->currency === $other->currency
            && bccomp($this->amount, $other->amount, 4) === 0;
    }

    /** VND không có đơn vị phụ — làm tròn về số nguyên khi hiển thị/xuất hoá đơn. */
    public function toDisplayUnits(): string
    {
        return $this->currency === 'VND'
            ? bcadd($this->amount, '0', 0)
            : bcadd($this->amount, '0', 2);
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new \DomainException(
                "Không thể tính toán giữa {$this->currency}{$other->currency}"
            );
        }
    }

    public function jsonSerialize(): array
    {
        return ['amount' => $this->amount, 'currency' => $this->currency];
    }
}

Vì sao private function __construct + static factory: ép mọi đường tạo object đi qua kiểm tra hợp lệ. Không có cách nào tạo ra một Money không hợp lệ trong toàn hệ thống.

Vì sao bcmath thay vì float: 0.1 + 0.2 !== 0.3. Ở e-commerce, sai số này tích luỹ thành lệch sổ và không bao giờ tìm ra nguyên nhân.

3.3 AddressSnapshot — ⚠ điểm dễ sai nhất

final readonly class AddressSnapshot
{
    public function __construct(
        public string $recipientName,
        public string $phone,           // đã chuẩn hoá +84
        public string $line1,           // số nhà, đường
        public ?string $ward,           // phường/xã — VĂN BẢN, không phải FK
        public ?string $district,       // ⚠ có thể null với địa chỉ tạo sau khi bỏ cấp huyện
        public string $province,        // tỉnh/thành — VĂN BẢN
        public string $countryCode,     // 'VN'
        public ?string $wardCode,       // mã tại thời điểm chụp, để tra cứu lịch sử
        public ?string $provinceCode,
        public ?string $note,
    ) {}
}

Ba lý do đây là snapshot văn bản, không phải khoá ngoại sang bảng địa chỉ (doc 01 §12.2):

  1. Đơn vị hành chính Việt Nam đã được tổ chức lại (sáp nhập tỉnh, chuyển sang mô hình hai cấp). Nếu orders trỏ FK sang bảng địa chỉ, mọi đơn cũ sẽ đổi địa chỉ khi bảng được cập nhật — hoá đơn in ra sai so với lúc giao.
  2. Khách sửa sổ địa chỉ của họ → đơn cũ không được đổi theo.
  3. Hãng vận chuyển cập nhật danh mục ở thời điểm khác nhau → wardCode giữ lại để tra cứu, nhưng văn bản mới là sự thật.

Bất biến: một khi Order được tạo, AddressSnapshot của nó không bao giờ đổi. Muốn đổi địa chỉ giao → tạo bản ghi thay đổi riêng (order_address_changes), không sửa đè.


4. Context: Catalog

Loại: Supporting · Module: modules/Catalog

Chịu trách nhiệm: sản phẩm bán cái gì, mô tả ra sao, thuộc danh mục nào, có những biến thể nào, ảnh gì.

KHÔNG chịu trách nhiệm: giá (→ Pricing), còn hàng không (→ Inventory), bán chạy không (→ Analytics).

⚠ Đây là ranh giới bị lấn nhiều nhất. Cám dỗ đặt pricestock vào bảng products rất lớn vì "trang danh sách cần hiển thị cả ba". Giải pháp đúng là read model cho trang danh sách (§21), không phải gộp aggregate.

4.1 Aggregate

Product (aggregate root)
├── ProductId, status, type
├── Translations[locale] : ProductTranslation   (name, slug, description)
├── Variants[]           : Variant              (entity con)
│   ├── VariantId, Sku, options{}, weight
│   ├── requiresBatchTracking, shelfLifeDays
│   └── status
├── attributes           : jsonb (điều khiển bởi AttributeDefinition)
├── categoryIds[]        : CategoryId[]   (tham chiếu bằng ID)
└── media[]              : MediaId[]

Category, Brand, AttributeDefinition, Media là các aggregate riêng.

4.2 Bất biến

# Bất biến
⚑ C1 Sku duy nhất toàn hệ thống. Kiểm ở Domain (qua Port) UNIQUE ở CSDL
⚑ C2 Product không thể active nếu không có ít nhất 1 Variant active
⚑ C3 Product không thể active nếu thiếu bản dịch của locale mặc định (vi)
⚑ C4 Không xoá cứng Variant đã từng xuất hiện trong order_items — chỉ archived
C5 slug duy nhất theo từng locale
C6 Sản phẩm type=configurable phải có ≥ 2 variant; type=simple có đúng 1
C7 Thuộc tính trong attributes phải khớp AttributeDefinition (kiểu + giá trị cho phép)

C4 là bất biến chống mất dữ liệu lịch sử: xoá variant → hoá đơn cũ không tra được sản phẩm.

4.3 Lệnh & sự kiện

Lệnh Sự kiện phát ra
CreateProduct catalog.product.created
AddVariant catalog.variant.created
UpdateProductContent catalog.product.content_updated
PublishProduct catalog.product.published
UnpublishProduct catalog.product.unpublished
ArchiveVariant catalog.variant.archived
AssignCategories catalog.product.categorized

⭐ = sự kiện có nhiều người nghe, cần thiết kế payload cẩn thận (§22).

Lắng nghe: không lắng nghe gì. Catalog là thượng nguồn.

4.4 Port

interface ProductRepository {
    public function findById(ProductId $id): ?Product;
    public function findBySku(Sku $sku): ?Product;
    public function skuExists(Sku $sku): bool;
    public function save(Product $product): void;
    public function nextIdentity(): ProductId;
}

interface SearchIndexer {                    // Infrastructure: PostgreSQL FTS → Typesense (P6)
    public function index(ProductId $id): void;
    public function remove(ProductId $id): void;
}

5. Context: Pricing

Loại: Core · Module: modules/Pricing

Chịu trách nhiệm: một Variant cụ thể, với một Customer cụ thể, ở một thời điểm cụ thể, có giá bao nhiêu.

KHÔNG chịu trách nhiệm: giảm giá theo mã/chương trình (→ Promotion). Phân biệt này rất quan trọng và hay bị gộp.

Pricing Promotion
Trả lời "Giá gốc của món này với khách này là bao nhiêu" "Khách này được giảm thêm bao nhiêu"
Phạm vi Từng dòng hàng Từng dòng hoặc cả đơn
Điều kiện Nhóm KH, số lượng, thời gian Mã, giỏ hàng, hạng thành viên, tồn kho
Kết quả list_price + sale_price Danh sách Discount áp lên dòng/đơn

5.1 Aggregate

PriceList (aggregate root)
├── PriceListId, code, currency, priority
├── customerGroupIds[]  (null = áp cho tất cả)
├── DateRange validity
└── PriceRule[]
    ├── variantId
    ├── minQuantity        (giá theo bậc số lượng)
    ├── listPrice, salePrice : Money
    └── DateRange validity  (khuyến mãi giá cấp sản phẩm)

5.2 Bất biến

# Bất biến
⚑ P1 salePrice <= listPrice
⚑ P2 salePrice >= 0
⚑ P3 Hai PriceRule cùng (variantId, minQuantity, customerGroup) không được chồng lấn thời gian — cưỡng chế bằng EXCLUDE ... WITH ... WITHOUT OVERLAPS ở CSDL
P4 minQuantity >= 1, các bậc không trùng nhau
P5 Mọi PriceList phải cùng currency với đơn hàng đang tính

P3 là lý do PostgreSQL 18 được chọn — CSDL tự chặn hai bảng giá chồng nhau, không cần code kiểm tra.

5.3 Thuật toán giải giá

resolvePrice(variantId, customerId, quantity, at) :
  1. Lấy customerGroupId của khách (mặc định: 'guest')
  2. Lọc các PriceList: đang hiệu lực tại `at` AND áp cho group này
  3. Sắp theo priority giảm dần
  4. Với PriceList đầu tiên có PriceRule khớp variantId
     và minQuantity <= quantity (chọn bậc cao nhất thoả) → trả về
  5. Không có → trả về giá mặc định của variant (PriceList 'base')
  6. Không có nữa → ném VariantNotPriced (⚑ KHÔNG mặc định về 0)

⚠ Bước 6 quan trọng: một sản phẩm không có giá phải báo lỗi, không được coi là miễn phí. Đây là lỗi kinh điển gây mất tiền thật.

5.4 Sự kiện

Sự kiện Người nghe
pricing.price_changed Search (reindex), Cache (purge), Analytics
pricing.price_list_activated Search (reindex hàng loạt)

6. Context: Promotion

Loại: Core · Module: modules/Promotion

Cảnh báo về độ phức tạp: khuyến mãi luôn phức tạp gấp 5 lần dự tính (doc 01 §17.3). Thiết kế dưới đây cố ý tách "điều kiện" khỏi "hiệu ứng" để tránh mê cung if.

6.1 Aggregate

Campaign (aggregate root)
├── CampaignId, name, priority, stackable: bool
├── DateRange validity
├── Condition[]     (tất cả phải thoả — AND)
│   ├── CartSubtotalAtLeast(Money)
│   ├── ContainsVariant(VariantId, minQty)
│   ├── ContainsCategory(CategoryId, minQty)
│   ├── CustomerGroupIs(GroupId[])
│   ├── CustomerTierIs(Tier[])
│   ├── FirstOrderOnly
│   └── UsageLimitNotReached
└── Effect[]        (áp dụng khi thoả)
    ├── PercentOffLine(Percentage, targetSelector)
    ├── FixedOffLine(Money, targetSelector)
    ├── PercentOffOrder(Percentage, maxDiscount: ?Money)
    ├── FreeShipping
    ├── GiftVariant(VariantId, qty)          ← mua X tặng Y
    └── BundlePrice(VariantId[], Money)      ← combo

Coupon (aggregate root RIÊNG)
├── CouponId, code, campaignId
├── maxRedemptions, maxRedemptionsPerCustomer
├── redemptionCount    ← bộ đếm có tranh chấp, xem §6.3
└── DateRange validity

Coupon tách khỏi CampaignredemptionCount là điểm nóng ghi, còn Campaign gần như chỉ đọc — gộp lại sẽ khiến mọi lần dùng mã đều khoá cả chương trình.

6.2 Bất biến

# Bất biến
⚑ M1 Tổng giảm giá không bao giờ vượt quá subtotal của đơn. Kết quả cuối luôn >= 0
⚑ M2 redemptionCount <= maxRedemptions
⚑ M3 Một (couponId, customerId) không vượt maxRedemptionsPerCustomerUNIQUE ở CSDL
⚑ M4 Campaign stackable = false không được cộng dồn với campaign khác
M5 GiftVariant chỉ tặng khi món quà còn tồn — nếu hết, chương trình vẫn áp phần còn lại
M6 Giảm giá được phân bổ về từng dòng để hoàn tiền một phần tính đúng (§6.4)

6.3 Chống lạm dụng mã giảm giá

Bộ đếm redemptionCounttài nguyên tranh chấp giống hệt tồn kho, và giải bằng đúng kỹ thuật đó (doc 01 §8.5):

UPDATE coupons
   SET redemption_count = redemption_count + 1
 WHERE id = :id
   AND (max_redemptions IS NULL OR redemption_count < max_redemptions)
   AND now() BETWEEN valid_from AND valid_to
RETURNING redemption_count;
-- 0 dòng → CouponExhausted

Cộng với UNIQUE (coupon_id, customer_id) trên coupon_redemptions cho giới hạn theo khách.

Bất biến quan trọng: mã được "giữ chỗ" lúc checkout (giống reservation tồn kho) và chỉ được xác nhận tiêu thụ khi đơn chuyển sang confirmed. Đơn huỷ → nhả lượt dùng. Không làm bước này thì mã 100 lượt sẽ bị đốt hết bởi các đơn không bao giờ thanh toán.

6.4 Phân bổ giảm giá về dòng — ⚠ điểm hay bị bỏ qua

Khi giảm 50.000₫ cho cả đơn gồm 3 dòng, số tiền đó phải được chia về từng dòng theo tỷ trọng, vì:

  • Trả một phần đơn → phải biết hoàn bao nhiêu cho đúng dòng đó.
  • Hoá đơn VAT tính thuế trên giá sau giảm của từng dòng.
  • Báo cáo lãi gộp theo sản phẩm.

Chia theo tỷ trọng line_total, phần dư do làm tròn dồn vào dòng có giá trị lớn nhất, và ⚑ tổng sau phân bổ phải bằng đúng số giảm ban đầu (có test riêng cho việc này).


7. Context: Inventory

Loại: Core — quan trọng nhất về tính đúng đắn · Module: modules/Inventory

Chịu trách nhiệm: còn bao nhiêu, ở kho nào, lô nào, giữ chỗ cho ai, hết hạn khi nào.

KHÔNG chịu trách nhiệm: giá vốn để báo cáo lãi (→ Procurement sở hữu unit_cost, Inventory chỉ mang theo).

7.1 Aggregate

StockItem (aggregate root) — khoá theo (VariantId, WarehouseId)
├── onHand    : int
├── reserved  : int
├── available : int (dẫn xuất = onHand - reserved)
└── version

Reservation (aggregate root RIÊNG)
├── ReservationId, variantId, warehouseId, quantity
├── reference (CheckoutSessionId | OrderId)
├── status: held | consumed | released | expired
└── expiresAt

Batch (aggregate root RIÊNG) — chỉ với variant requiresBatchTracking
├── BatchId, batchCode, expiresAt, quantity, unitCost
└── warehouseId, variantId

StockMovement — APPEND ONLY, không phải aggregate mà là bản ghi sổ

7.2 Bất biến — nhóm nghiêm ngặt nhất của toàn dự án

# Bất biến
⚑ I1 reserved >= 0
⚑ I2 onHand >= 0
⚑ I3 reserved <= onHandđây chính là "không oversell"
⚑ I4 Mọi thay đổi onHand phải có StockMovement tương ứng. Tổng movement = onHand hiện tại
⚑ I5 Không giữ chỗ hai lần cho cùng (reference, variantId)UNIQUE ở CSDL
⚑ I6 Reservation quá hạn phải được nhả trong ≤ 60 giây
⚑ I7 Với variant có requiresBatchTracking: SUM(batch.quantity) == stockItem.onHand
⚑ I8 Không phân bổ lô đã hết hạn hoặc còn dưới ngưỡng ngày hạn dùng tối thiểu

I3 được cưỡng chế ở ba tầng độc lập: CHECK constraint trong PostgreSQL, mệnh đề WHERE của câu UPDATE nguyên tử, và bất biến trong aggregate. Ba tầng vì đây là lỗi duy nhất trong hệ thống mà "hiếm khi xảy ra" vẫn là không chấp nhận được.

I7 là bất biến xuyên aggregate, không cưỡng chế được trong một transaction. Xử lý bằng job đối chiếu chạy hằng đêm, lệch → cảnh báo P0.

7.3 Aggregate StockItem — mã nguồn

<?php
declare(strict_types=1);

namespace Modules\Inventory\Domain\Entity;

use Modules\Inventory\Domain\Exception\InsufficientStock;
use Modules\Shared\Domain\VariantId;

final class StockItem
{
    public function __construct(
        public readonly VariantId $variantId,
        public readonly WarehouseId $warehouseId,
        public private(set) int $onHand,      // PHP 8.4: đọc công khai, chỉ sửa từ trong
        public private(set) int $reserved,
        public private(set) int $version,
    ) {
        $this->assertInvariants();
    }

    public int $available { get => $this->onHand - $this->reserved; }   // property hook 8.4

    /**
     * ⚠ Phương thức này KHÔNG phải nơi chống race condition.
     * Chống race nằm ở câu UPDATE nguyên tử trong EloquentStockRepository
     * (doc 01 §8.5). Ở đây chỉ là bất biến trong bộ nhớ, để test nhanh
     * và để aggregate không bao giờ tự đưa mình vào trạng thái sai.
     */
    public function reserve(int $quantity): void
    {
        $this->assertPositive($quantity);

        if ($quantity > $this->available) {
            throw InsufficientStock::for($this->variantId, $quantity, $this->available);
        }

        $this->reserved += $quantity;
        $this->assertInvariants();
    }

    public function release(int $quantity): void
    {
        $this->assertPositive($quantity);

        if ($quantity > $this->reserved) {
            throw new \DomainException(
                "Nhả nhiều hơn số đang giữ: nhả {$quantity}, đang giữ {$this->reserved}"
            );
        }

        $this->reserved -= $quantity;
        $this->assertInvariants();
    }

    /** Xuất kho thật: giảm CẢ onHand lẫn reserved. */
    public function deduct(int $quantity): void
    {
        $this->assertPositive($quantity);

        if ($quantity > $this->reserved) {
            throw new \DomainException(
                'Chỉ được trừ kho phần đã giữ chỗ. '
                . "Trừ {$quantity}, đang giữ {$this->reserved}"
            );
        }

        $this->onHand   -= $quantity;
        $this->reserved -= $quantity;
        $this->assertInvariants();
    }

    public function receive(int $quantity): void
    {
        $this->assertPositive($quantity);
        $this->onHand += $quantity;
        $this->assertInvariants();
    }

    /** Kiểm kê: đặt lại onHand theo số đếm thực tế. */
    public function adjustTo(int $countedQuantity): int
    {
        if ($countedQuantity < $this->reserved) {
            throw new \DomainException(
                "Số kiểm kê ({$countedQuantity}) nhỏ hơn số đang giữ chỗ ({$this->reserved}). "
                . 'Phải huỷ bớt đơn trước khi điều chỉnh.'
            );
        }

        $delta = $countedQuantity - $this->onHand;
        $this->onHand = $countedQuantity;
        $this->assertInvariants();

        return $delta;    // ghi vào StockMovement với reason='adjustment'
    }

    private function assertPositive(int $q): void
    {
        if ($q <= 0) {
            throw new \InvalidArgumentException("Số lượng phải dương, nhận: {$q}");
        }
    }

    private function assertInvariants(): void
    {
        // ⚑ I1, I2, I3
        if ($this->onHand < 0)              throw new \DomainException('onHand âm');
        if ($this->reserved < 0)            throw new \DomainException('reserved âm');
        if ($this->reserved > $this->onHand) throw new \DomainException('reserved > onHand — OVERSELL');
    }
}

Chú ý public private(set) (PHP 8.4, đã verify chạy trên máy — doc 01 §2.1): bên ngoài đọc $stock->onHand thoải mái nhưng không thể gán. Không cần viết getter, mà bất biến vẫn được bảo vệ. Đây là lý do kỹ thuật để chọn PHP 8.4+ cho tầng Domain.

7.4 Phân bổ lô theo FEFO

allocateBatches(variantId, warehouseId, quantity, minShelfLifeDays, at) :
  1. Lấy các Batch: quantity > 0
                AND expiresAt > at + minShelfLifeDays        ⚑ I8
     sắp xếp theo expiresAt TĂNG DẦN                          ← FEFO
  2. Lấy dần từ lô hết hạn sớm nhất cho đến đủ quantity
  3. Không đủ → ném InsufficientAllocatableStock
     ⚠ KHÁC với InsufficientStock: có hàng nhưng hàng cận date,
       cần cảnh báo vận hành chứ không phải báo khách "hết hàng"
  4. Trả về BatchAllocation[] { batchId, quantity, unitCost }

unitCost được mang theo để Order chốt giá vốn tại thời điểm xuất — nền tảng của báo cáo lãi gộp chính xác.

7.5 Lệnh & sự kiện

Lệnh Sự kiện
ReserveStock inventory.stock_reserved
ReleaseStock inventory.stock_released
ConsumeReservation inventory.stock_deducted
ReceiveStock inventory.stock_received
AdjustStock inventory.stock_adjusted
TransferStock inventory.stock_transferred
(job định kỳ) inventory.reservation_expired
(job định kỳ) inventory.low_stock_detected, inventory.batch_expiring_soon

Lắng nghe: order.cancelled → nhả giữ chỗ · returns.received → nhập kho lại · checkout.session_expired → nhả giữ chỗ.


8. Context: Cart

Loại: Supporting · Module: modules/Cart

Chịu trách nhiệm: khách đang định mua gì. Giá động, tính lại mỗi lần đọc.

KHÔNG chịu trách nhiệm: giữ chỗ tồn kho (⚠ giỏ hàng không giữ chỗ — chỉ checkout mới giữ).

8.1 Bất biến

# Bất biến
⚑ K1 Một khách đã đăng nhập có tối đa 1 giỏ active
⚑ K2 Không có 2 dòng cùng variantId — cộng dồn số lượng
K3 quantity >= 1; đặt về 0 = xoá dòng
K4 Giỏ chứa variant đã archived → dòng bị đánh dấu unavailable, không tự xoá (khách phải thấy nó biến mất và biết vì sao)
K5 Giỏ khách vãng lai sống 30 ngày; giỏ đã đăng nhập sống 90 ngày

8.2 Gộp giỏ khi đăng nhập — ⚠ hay làm sai

mergeCarts(guestCart, userCart) :
  Với mỗi dòng trong guestCart:
    - Nếu userCart đã có variant đó → lấy MAX(qty), không cộng dồn
      (khách thêm 2 lúc chưa đăng nhập và 3 lúc đã đăng nhập
       thường có nghĩa là muốn 3, không phải 5)
    - Nếu chưa có → thêm vào
  Kiểm tra lại toàn bộ: variant còn active? còn hàng? giá đổi chưa?
  Thông báo rõ cho khách những dòng bị thay đổi.

8.3 Sự kiện

cart.item_added · cart.item_removed · cart.cleared · cart.merged · cart.abandoned (job: không hoạt động 24 giờ và có ≥ 1 dòng → Notification gửi email nhắc)


9. Context: Checkout

Loại: Core · Module: modules/Checkout

Chịu trách nhiệm: biến giỏ hàng thành đơn hàng một cách an toàn. Đây là ranh giới giá đóng băng (doc 01 §8.4).

9.1 Aggregate

CheckoutSession (aggregate root)
├── CheckoutSessionId, cartId, customerId?
├── shippingAddress : AddressSnapshot
├── billingAddress  : AddressSnapshot
├── shippingMethod, shippingFee : Money
├── paymentMethod
├── lines[] : CheckoutLine  ← ĐÃ SNAPSHOT giá, thuế, giảm giá
├── couponCodes[]
├── priceSnapshotHash : string
├── reservationIds[]  : ReservationId[]
├── status : draft | pricing_locked | reserved | completed | expired | failed
└── expiresAt (15 phút)

9.2 Bất biến

# Bất biến
⚑ H1 Không tạo Order nếu chưa giữ chỗ đủ toàn bộ dòng hàng
⚑ H2 Session hết hạn → bắt buộc nhả toàn bộ reservation
⚑ H3 priceSnapshotHash phải khớp lúc chốt đơn, nếu không phải tính lại
⚑ H4 Tổng tiền = Σ line_total + shippingFee − orderDiscount + tax, và >= 0
⚑ H5 Địa chỉ giao phải nằm trong vùng phục vụ của phương thức vận chuyển đã chọn
H6 COD chỉ khả dụng khi tổng đơn ≤ hạn mức COD của khách (Policy)

9.3 Quy trình 8 bước

1. Kiểm tra giỏ            → dòng còn bán được không, variant còn active không
2. Giải giá  (Pricing)     → list_price, sale_price từng dòng
3. Áp khuyến mãi (Promotion) → giữ chỗ lượt dùng coupon, phân bổ giảm giá về dòng
4. Tính phí ship (Fulfilment) → theo địa chỉ + trọng lượng + phương thức
5. Tính thuế (Tax)         → theo thuế suất có hiệu lực tại thời điểm này
6. CHỐT: tính priceSnapshotHash, chuyển sang pricing_locked, đặt expiresAt = +15′
7. GIỮ CHỖ (Inventory)     → reserve từng dòng.
   ⚑ Thất bại ở dòng thứ k → nhả toàn bộ 1..k-1 rồi báo lỗi (all-or-nothing)
8. TẠO ĐƠN (Order)         → transaction riêng. Thất bại → nhả toàn bộ chỗ đã giữ

Thứ tự bước 6 trước bước 7 là cố ý. Nếu giữ chỗ trước rồi mới tính giá, một lỗi khi tính giá sẽ để lại chỗ giữ mồ côi. Chốt giá là thao tác không có tác dụng phụ, làm trước thì rẻ khi phải huỷ.

9.4 Xử lý giá đổi giữa chừng

Tình huống Xử lý
Giá giảm so với lúc khách xem Tự động áp giá mới, hiện thông báo "giá đã giảm" — không chặn
Giá tăng Chặn, quay lại bước xác nhận, hiển thị chênh lệch rõ ràng. ⚑ Không bao giờ âm thầm tính giá cao hơn giá khách đã thấy
Coupon hết hiệu lực giữa chừng Chặn, thông báo, tính lại tổng
Hết hàng ở bước 7 Chặn, chỉ rõ dòng nào, gợi ý giảm số lượng hoặc bỏ dòng

9.5 Sự kiện

checkout.session_started · checkout.pricing_locked · checkout.completed ⭐ · checkout.session_expired · checkout.failed


10. Context: Order

Loại: Core · Module: modules/Order

Chịu trách nhiệm: đơn hàng đã chốt: trạng thái, nội dung, lịch sử. Đây là sổ ghi nhận cam kết với khách.

KHÔNG chịu trách nhiệm: thu tiền (→ Payment), giao hàng (→ Fulfilment), trừ kho (→ Inventory). Order điều phối nhưng không thực hiện.

10.1 Ba trục trạng thái độc lập

Nhắc lại từ doc 01 §6, vì đây là quyết định hay bị hiểu nhầm nhất:

status            : draft → pending_payment → confirmed → processing → shipped
                    → delivered → completed        (+ cancelled, returning, returned)
paymentStatus     : unpaid → authorized → paid → partially_refunded → refunded
fulfilmentStatus  : unfulfilled → partially_fulfilled → fulfilled  (+ cancelled)

⚠ Gộp ba trục làm một status duy nhất là lỗi thiết kế phổ biến nhất trong e-commerce. Nó khiến "đơn đã giao nhưng chưa thu được tiền COD" hoặc "đơn đã thanh toán, giao được 2/3 kiện" không biểu diễn được, và cuối cùng người ta thêm cột is_paid, is_shipped, is_partially_... — về đúng chỗ lẽ ra nên bắt đầu.

10.2 Aggregate

Order (aggregate root)
├── OrderId (uuidv7 công khai) + orderNumber (mã khách đọc, vd "HS26081300123")
├── customerId?, customerSnapshot (tên, email, phone tại thời điểm đặt)
├── status, paymentStatus, fulfilmentStatus
├── lines[] : OrderLine     ← BẤT BIẾN sau khi tạo
│   ├── variantId, sku, name          (snapshot)
│   ├── unitPrice, listPrice : Money  (snapshot)
│   ├── discountTotal, taxRate, taxTotal, lineTotal
│   ├── quantity, quantityFulfilled, quantityReturned
│   ├── unitCost : Money              (giá vốn lô, snapshot từ Inventory)
│   └── appliedPromotions[]           (vết truy nguyên)
├── shippingAddress, billingAddress : AddressSnapshot
├── shippingFee, orderDiscount, subtotal, taxTotal, grandTotal : Money
├── paidAmount, refundedAmount : Money
├── statusHistory[] : StatusTransition  (from, to, at, by, reason)
├── placedAt, confirmedAt, shippedAt, deliveredAt, completedAt, cancelledAt
└── cancellationReason?

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí