0

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

10.3 Bất biến

# Bất biến
⚑ O1 grandTotal = subtotal − orderDiscount + shippingFee + taxTotal, luôn >= 0
⚑ O2 lines không đổi sau khi tạo. Sửa đơn = huỷ + tạo mới, hoặc tách đơn
⚑ O3 Chỉ chuyển trạng thái theo bảng §10.4. Không có $order->status = ...
⚑ O4 refundedAmount <= paidAmount
⚑ O5 quantityFulfilled <= quantity với mọi dòng
⚑ O6 quantityReturned <= quantityFulfilled
⚑ O7 Đơn cancelled không thể chuyển sang bất kỳ trạng thái nào khác — trạng thái tuyệt đối cuối
⚑ O8 orderNumber duy nhất, sinh một lần, không tái sử dụng kể cả khi đơn bị huỷ
O9 Mọi chuyển trạng thái ghi StatusTransition với byreason

10.4 Bảng chuyển trạng thái được phép

Từ Sang Ai kích hoạt Điều kiện Sự kiện
draft pending_payment Checkout Đã giữ chỗ đủ order.placed
pending_payment confirmed Webhook thanh toán / duyệt COD paymentStatus = paid hoặc COD được duyệt order.confirmed
pending_payment cancelled Job hết hạn / khách / thanh toán fail order.cancelled
confirmed processing Kho Đã phân bổ lô order.processing_started
confirmed cancelled Admin Chưa xuất kho order.cancelled
processing shipped Fulfilment Đã tạo ≥ 1 vận đơn order.shipped
processing cancelled Admin Chưa bàn giao hãng vận chuyển order.cancelled
shipped delivered Webhook hãng vận chuyển order.delivered
shipped returning Hãng trả về (khách từ chối nhận) order.return_started
delivered completed Job hết hạn đổi trả Quá 7–15 ngày order.completed
delivered returning Khách yêu cầu trả Còn hạn đổi trả order.return_started
returning returned Returns Đã nhận hàng về order.returned

Mọi cặp không có trong bảng đều ném IllegalStateTransition.

10.5 Aggregate Order — mã nguồn phần cốt lõi

<?php
declare(strict_types=1);

namespace Modules\Order\Domain\Entity;

enum OrderStatus: string
{
    case Draft           = 'draft';
    case PendingPayment  = 'pending_payment';
    case Confirmed       = 'confirmed';
    case Processing      = 'processing';
    case Shipped         = 'shipped';
    case Delivered       = 'delivered';
    case Completed       = 'completed';
    case Cancelled       = 'cancelled';
    case Returning       = 'returning';
    case Returned        = 'returned';

    /** Bảng chuyển trạng thái §10.4 — nguồn sự thật DUY NHẤT. */
    public function allowedNext(): array
    {
        return match ($this) {
            self::Draft          => [self::PendingPayment, self::Cancelled],
            self::PendingPayment => [self::Confirmed, self::Cancelled],
            self::Confirmed      => [self::Processing, self::Cancelled],
            self::Processing     => [self::Shipped, self::Cancelled],
            self::Shipped        => [self::Delivered, self::Returning],
            self::Delivered      => [self::Completed, self::Returning],
            self::Returning      => [self::Returned],
            self::Completed,
            self::Cancelled,
            self::Returned       => [],          // ⚑ O7: trạng thái cuối
        };
    }

    public function canTransitionTo(self $next): bool
    {
        return in_array($next, $this->allowedNext(), strict: true);
    }

    public function isFinal(): bool
    {
        return $this->allowedNext() === [];
    }
}

final class Order
{
    /** @var DomainEvent[] */
    private array $recordedEvents = [];

    public function __construct(
        public readonly OrderId $id,
        public readonly string $orderNumber,
        public readonly ?CustomerId $customerId,
        /** @var OrderLine[] */
        public readonly array $lines,                  // ⚑ O2: bất biến
        public readonly AddressSnapshot $shippingAddress,
        public readonly Money $shippingFee,
        public readonly Money $orderDiscount,
        public readonly Money $taxTotal,
        public private(set) OrderStatus $status,
        public private(set) PaymentStatus $paymentStatus,
        public private(set) FulfilmentStatus $fulfilmentStatus,
        public private(set) Money $paidAmount,
        public private(set) Money $refundedAmount,
        public readonly \DateTimeImmutable $placedAt,
    ) {
        if ($lines === []) {
            throw new \DomainException('Đơn hàng phải có ít nhất một dòng');
        }
        $this->assertTotalsConsistent();
    }

    public Money $subtotal {
        get => array_reduce(
            $this->lines,
            static fn (Money $c, OrderLine $l) => $c->plus($l->lineTotal),
            Money::zero($this->shippingFee->currency),
        );
    }

    public Money $grandTotal {                                        // ⚑ O1
        get => $this->subtotal
            ->minus($this->orderDiscount)
            ->plus($this->shippingFee)
            ->plus($this->taxTotal);
    }

    public Money $outstandingAmount {
        get => $this->grandTotal->minus($this->paidAmount)->plus($this->refundedAmount);
    }

    // ─────────── Chuyển trạng thái ───────────

    private function transitionTo(
        OrderStatus $next,
        \DateTimeImmutable $at,
        ?UserId $by,
        ?string $reason = null,
    ): void {
        if (!$this->status->canTransitionTo($next)) {                 // ⚑ O3
            throw IllegalStateTransition::from($this->status, $next, $this->id);
        }

        $previous     = $this->status;
        $this->status = $next;

        $this->recordEvent(new OrderStatusChanged(
            orderId: $this->id, from: $previous, to: $next,
            at: $at, by: $by, reason: $reason,
        ));
    }

    public function confirm(\DateTimeImmutable $at, ?UserId $by = null): void
    {
        if (!$this->paymentStatus->isSettledOrCod()) {
            throw new \DomainException(
                'Không thể xác nhận đơn chưa thanh toán và không phải COD'
            );
        }

        $this->transitionTo(OrderStatus::Confirmed, $at, $by);
        $this->recordEvent(new OrderConfirmed($this->id, $at));
    }

    public function cancel(string $reason, \DateTimeImmutable $at, ?UserId $by = null): void
    {
        if ($this->status->isFinal()) {                               // ⚑ O7
            throw new \DomainException(
                "Không thể huỷ đơn ở trạng thái cuối: {$this->status->value}"
            );
        }
        if ($this->fulfilmentStatus === FulfilmentStatus::Fulfilled) {
            throw new \DomainException('Không thể huỷ đơn đã giao xong — dùng luồng trả hàng');
        }

        $this->transitionTo(OrderStatus::Cancelled, $at, $by, $reason);

        // Người nghe sẽ: nhả giữ chỗ tồn, nhả lượt coupon, hoàn tiền nếu đã thu
        $this->recordEvent(new OrderCancelled(
            orderId: $this->id,
            reason: $reason,
            refundRequired: $this->paidAmount->isGreaterThan(Money::zero($this->paidAmount->currency)),
            at: $at,
        ));
    }

    // ─────────── Tiền ───────────

    public function registerPayment(Money $amount, \DateTimeImmutable $at): void
    {
        if ($amount->isNegative() || $amount->isZero()) {
            throw new \InvalidArgumentException('Số tiền thanh toán phải dương');
        }

        $this->paidAmount = $this->paidAmount->plus($amount);

        $this->paymentStatus = $this->paidAmount->isGreaterThan($this->grandTotal)
            ? throw new \DomainException('Thu vượt quá giá trị đơn hàng')
            : ($this->paidAmount->equals($this->grandTotal)
                ? PaymentStatus::Paid
                : PaymentStatus::PartiallyPaid);

        $this->recordEvent(new OrderPaymentRegistered($this->id, $amount, $at));
    }

    public function registerRefund(Money $amount, \DateTimeImmutable $at): void
    {
        $newRefunded = $this->refundedAmount->plus($amount);

        if ($newRefunded->isGreaterThan($this->paidAmount)) {          // ⚑ O4
            throw new \DomainException(
                "Hoàn vượt quá số đã thu: hoàn {$newRefunded->amount}, đã thu {$this->paidAmount->amount}"
            );
        }

        $this->refundedAmount = $newRefunded;
        $this->paymentStatus  = $newRefunded->equals($this->paidAmount)
            ? PaymentStatus::Refunded
            : PaymentStatus::PartiallyRefunded;

        $this->recordEvent(new OrderRefundRegistered($this->id, $amount, $at));
    }

    // ─────────── Giao hàng ───────────

    public function registerFulfilment(OrderLineId $lineId, int $quantity): void
    {
        $line = $this->lineById($lineId);
        $line->fulfil($quantity);                                     // ⚑ O5 kiểm trong OrderLine

        $this->fulfilmentStatus = $this->allLinesFulfilled()
            ? FulfilmentStatus::Fulfilled
            : FulfilmentStatus::PartiallyFulfilled;
    }

    private function allLinesFulfilled(): bool
    {
        foreach ($this->lines as $line) {
            if ($line->quantityFulfilled < $line->quantity) {
                return false;
            }
        }
        return true;
    }

    // ─────────── Sự kiện ───────────

    /** @return DomainEvent[] */
    public function releaseEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];
        return $events;                    // Application layer ghi vào outbox
    }

    private function recordEvent(DomainEvent $e): void
    {
        $this->recordedEvents[] = $e;
    }

    private function assertTotalsConsistent(): void
    {
        if ($this->grandTotal->isNegative()) {                        // ⚑ O1
            throw new \DomainException('Tổng đơn hàng không thể âm');
        }
    }
}

Về releaseEvents(): aggregate ghi nhận sự kiện chứ không phát sự kiện. Application layer lấy ra và ghi vào bảng outbox_messages trong cùng transaction với việc lưu aggregate (doc 01 §8.8). Không bao giờ dispatch trực tiếp từ Domain — làm vậy thì transaction rollback sẽ để lại sự kiện đã phát về một chuyện chưa từng xảy ra.

10.6 Huỷ một phần = tách đơn

Không sửa lines của đơn gốc (⚑ O2). Thay vào đó:

splitOrder(orderId, linesToSplit[]) :
  1. Tạo Order mới (orderNumber có hậu tố "-2") chứa các dòng được tách
  2. Đơn gốc chuyển sang trạng thái 'superseded_partially'
     kèm liên kết parentOrderId ↔ childOrderIds
  3. Phân bổ lại: shippingFee, orderDiscount, tax theo tỷ trọng
     ⚑ Tổng grandTotal các đơn con = grandTotal đơn gốc
  4. Chuyển giữ chỗ tồn kho sang đơn con tương ứng
  5. Đơn con nào bị huỷ thì huỷ độc lập

Cách này giữ được lịch sử nguyên vẹn và làm đối soát kế toán khớp. Sửa đè lines sẽ khiến hoá đơn đã phát hành không khớp với đơn hàng.


11. Context: Payment

Loại: Core · Module: modules/Payment

Chịu trách nhiệm: tiền vào, tiền ra, và sổ sách luôn cân bằng.

11.1 Hai aggregate tách bạch

PaymentTransaction (aggregate root)         ← giao dịch với cổng thanh toán
├── PaymentTransactionId, orderId, gateway ('vnpay'|'momo'|'zalopay'|'cod')
├── gatewayTransactionId?, amount : Money
├── status: pending | authorized | captured | failed | voided | refunded
├── idempotencyKey                          ← chống webhook lặp
└── rawGatewayPayload : jsonb               (lưu nguyên văn để đối soát/tranh chấp)

LedgerTransaction (aggregate root)          ← bút toán kế toán kép
├── LedgerTransactionId, description, reference
└── entries[] : LedgerEntry { accountCode, amount }   ⚑ Σ amount = 0

⚠ Tách hai aggregate này là quyết định quan trọng: PaymentTransaction phản ánh thực tế với cổng thanh toán (có thể fail, có thể lặp, có thể mất). LedgerTransaction phản ánh sự thật kế toán (không bao giờ lệch). Gộp chúng thì một webhook lỗi sẽ làm hỏng sổ sách.

11.2 Bất biến

# Bất biến
⚑ Y1 Σ ledger_entries.amount = 0 cho mỗi LedgerTransaction — cưỡng chế bằng CONSTRAINT TRIGGER DEFERRABLE (doc 01 §8.7)
⚑ Y2 Một idempotencyKey chỉ tạo đúng một PaymentTransaction
⚑ Y3 Không capture quá số đã authorize
⚑ Y4 Không refund quá số đã capture
⚑ Y5 LedgerEntry là append-only — sửa sai bằng bút toán đảo, không bao giờ UPDATE/DELETE
⚑ Y6 Mỗi PaymentTransaction thành công phải có đúng một LedgerTransaction tương ứng
Y7 rawGatewayPayload lưu vĩnh viễn (theo quy định lưu trữ chứng từ)

11.3 Sơ đồ tài khoản (chart of accounts)

Mã tài khoản Loại Ý nghĩa
cash.vnpay asset Tiền đang ở VNPay, chưa về ngân hàng
cash.momo, cash.zalopay asset Tương tự
cash.bank asset Tiền đã về tài khoản ngân hàng
ar.cod.ghn, ar.cod.ghtk asset Phải thu tiền COD từ hãng vận chuyển
revenue.sales revenue Doanh thu bán hàng
revenue.shipping revenue Thu phí vận chuyển
liability.tax_payable liability Thuế VAT phải nộp
liability.giftcard liability Giá trị thẻ quà tặng chưa dùng
liability.loyalty_points liability Điểm thưởng chưa quy đổi
expense.payment_fee expense Phí cổng thanh toán
expense.shipping_cost expense Chi phí trả hãng vận chuyển
expense.cogs expense Giá vốn hàng bán
contra.refunds revenue Hoàn tiền (giảm trừ doanh thu)

11.4 Bút toán mẫu

Thanh toán VNPay thành công, đơn 1.100.000₫ (hàng 1.000.000₫ + ship 100.000₫), VAT 10% đã gồm trong giá, giá vốn 600.000₫:

Tài khoản Nợ
cash.vnpay 1.100.000
revenue.sales 909.091
revenue.shipping 90.909
liability.tax_payable 100.000
Tổng 1.100.000 1.100.000

Đồng thời ghi nhận giá vốn:

Tài khoản Nợ
expense.cogs 600.000
asset.inventory 600.000

Đơn COD 1.100.000₫ được giao thành công qua GHN:

Tài khoản Nợ
ar.cod.ghn 1.100.000
revenue.sales + revenue.shipping + liability.tax_payable 1.100.000

GHN chuyển khoản đối soát, giữ lại phí thu hộ 20.000₫:

Tài khoản Nợ
cash.bank 1.080.000
expense.shipping_cost 20.000
ar.cod.ghn 1.100.000

Đây là lý do phải có sổ kép: số dư ar.cod.ghn tại bất kỳ thời điểm nào chính là số tiền hãng vận chuyển đang nợ bạn. Không có sổ kép thì con số này không tồn tại, và tiền thất thoát trong khâu đối soát COD sẽ không ai phát hiện.

11.5 Webhook — chống lặp và chống replay

handleWebhook(gateway, rawPayload, signature) :
  1. Xác thực chữ ký nhà cung cấp        → sai: 401, ghi log cảnh báo
  2. Kiểm tra timestamp trong ngưỡng 5′  → quá hạn: 400 (chống replay)
  3. idempotencyKey = hash(gateway, gatewayTransactionId, eventType)
  4. INSERT idempotency_keys ... ON CONFLICT DO NOTHING
     0 dòng → đã xử lý rồi → trả về response đã lưu, HTTP 200
     ⚑ PHẢI trả 200. Trả lỗi sẽ khiến cổng thanh toán retry vô hạn.
  5. Xử lý trong transaction:
     - Cập nhật PaymentTransaction
     - Ghi LedgerTransaction
     - Ghi outbox: payment.captured
     - Lưu response vào idempotency_keys
  6. Trả 200

⚠ Bước 4 dùng INSERT ... ON CONFLICT DO NOTHING chứ không dùng SELECT rồi INSERT — hai webhook đến đồng thời sẽ vượt qua SELECT cùng lúc và tạo hai giao dịch.

11.6 Sự kiện

payment.authorized · payment.captured ⭐ · payment.failed · payment.voided · payment.refunded ⭐ · payment.cod_collected · payment.reconciliation_mismatch ⭐ (→ cảnh báo P0)


12. Context: Fulfilment

Loại: Supporting · Module: modules/Fulfilment

12.1 Aggregate

Shipment (aggregate root)
├── ShipmentId, orderId, carrier ('ghn'|'ghtk'|'viettelpost'|'ahamove')
├── trackingNumber?, labelUrl?
├── lines[] : { orderLineId, quantity, batchAllocations[] }
├── weight, dimensions, codAmount : Money
├── status: draft | ready | handed_over | in_transit | delivered
│           | failed_delivery | returning | returned
├── attemptCount                              ← số lần giao thất bại
└── carrierEvents[] : { code, description, at, rawPayload }

12.2 Bất biến

# Bất biến
⚑ F1 Σ shipment.lines.quantity <= order.line.quantity với mọi dòng
⚑ F2 Một Order có thể có nhiều Shipment (giao nhiều kiện)
⚑ F3 codAmount > 0 chỉ khi phương thức thanh toán là COD, và Σ codAmount các kiện = order.grandTotal
⚑ F4 Chỉ tạo Shipment khi đơn ở confirmed hoặc processing
F5 handed_over là điểm trừ kho thật (inventory.stock_deducted)
F6 3 lần giao thất bại → tự động chuyển returning, cảnh báo CSKH

12.3 ACL với hãng vận chuyển

interface CarrierGateway {
    public function quote(ShippingQuoteRequest $r): ShippingQuote;
    public function createShipment(ShipmentRequest $r): CarrierShipmentResult;
    public function cancelShipment(string $trackingNumber): void;
    public function fetchLabel(string $trackingNumber): LabelDocument;
    public function normalizeWebhook(array $raw): CarrierEvent;   // ⭐ chuẩn hoá về từ vựng chung
}

Nguyên tắc ACL không thương lượng: mã trạng thái riêng của GHN (ready_to_pick, picking, delivering…) và của GHTK (-1, 1, 2, 3…) không được rò rỉ vào Domain. normalizeWebhook() dịch chúng về CarrierEvent với từ vựng của dự án. Thêm hãng thứ tư = viết một adapter, không sửa Order.

Danh mục địa chỉ: mỗi hãng có bộ mã tỉnh/phường riêng và cập nhật ở thời điểm khác nhau sau khi đơn vị hành chính được tổ chức lại (doc 01 §12.2). Mỗi adapter phải có bảng ánh xạ riêng carrier_address_mappings(carrier, our_code, carrier_code, valid_from).


13. Context: Returns

Loại: Supporting · Module: modules/Returns

ReturnRequest (aggregate root)
├── ReturnRequestId, orderId, customerId
├── lines[] : { orderLineId, quantity, reason, condition }
│             reason: defective | wrong_item | not_as_described
│                   | changed_mind | expired | damaged_in_transit
│             condition: resellable | damaged | opened | expired
├── status: requested | approved | rejected | shipping_back
│           | received | inspected | refunded | closed
├── refundAmount : Money
├── restockDecision: restock | scrap | quarantine
└── evidencePhotos[]

Bất biến

# Bất biến
⚑ R1 Chỉ trả được phần đã giao: quantity <= orderLine.quantityFulfilled − quantityReturned
⚑ R2 refundAmount <= phần tương ứng của paidAmount sau khi trừ giảm giá đã phân bổ về dòng (§6.4)
⚑ R3 Chỉ nhập kho lại khi condition = resellable lô còn hạn đủ dài (⚑ I8)
⚑ R4 Mỹ phẩm đã mở nắp → condition != resellable, không bao giờ nhập kho lại
R5 Ngoài hạn đổi trả → phải có phê duyệt của quản lý, ghi rõ lý do
R6 Hoàn tiền chỉ về đúng phương thức đã thanh toán (COD → chuyển khoản, cần thông tin ngân hàng)

⚑ R4 vừa là quy định pháp lý vừa là bảo vệ thương hiệu — mã hoá thành bất biến chứ không để phụ thuộc vào việc nhân viên nhớ.


14. Context: Tax & Invoice

Loại: Supporting (nhưng ràng buộc pháp lý cứng) · Module: modules/Tax

TaxRate (aggregate root)
├── TaxRateId, code ('vat_standard'), rate : Percentage
├── DateRange validity          ⚑ BẮT BUỘC có hiệu lực theo thời gian
└── appliesTo: categoryIds[] | 'all'

Invoice (aggregate root)
├── InvoiceId, orderId, invoiceNumber, taxAuthorityCode?
├── type: original | adjustment | replacement
├── issuedAt, lines[] (snapshot ĐỘC LẬP với order_items)
├── status: draft | issued | signed | sent | cancelled | replaced
└── providerPayload : jsonb

Bất biến

# Bất biến
⚑ T1 Hoá đơn đã phát hành không sửa được. Sai → phát hành hoá đơn điều chỉnh/thay thế
⚑ T2 Thuế suất áp dụng là thuế suất có hiệu lực tại thời điểm phát hành, lưu snapshot vào hoá đơn
⚑ T3 Σ invoice.lines = order.grandTotal tại thời điểm phát hành
⚑ T4 Một đơn có tối đa một hoá đơn original đang hiệu lực
T5 Trả hàng → hoá đơn điều chỉnh giảm, không xoá hoá đơn gốc

Thời điểm phát hành hoá đơn (khi giao hàng hay khi thu tiền) là quyết định kế toán, không phải quyết định kỹ thuật. Phải hỏi kế toán trước khi code. Mô hình này hỗ trợ cả hai bằng một Policy có thể cấu hình.

⏳ Chi tiết tích hợp nhà cung cấp hoá đơn điện tử → 09-vietnam-compliance.md (P3).


15. Context: Customer & IAM

Customer (Supporting) — hồ sơ khách, sổ địa chỉ, nhóm khách, hạng thành viên. IAM (Generic) — dùng Laravel auth + Sanctum + spatie/laravel-permission, không xây Domain layer riêng.

⚠ Tách Customer khỏi User là cố ý: một Customer có thể chưa có tài khoản (mua với tư cách khách vãng lai rồi mới đăng ký sau), và một User có thể là nhân viên không phải khách. Gộp hai khái niệm này sẽ bế tắc khi làm chức năng "gộp đơn khách vãng lai vào tài khoản mới đăng ký".

Bất biến

# Bất biến
⚑ U1 email duy nhất trong users; phone duy nhất trong customers (đã chuẩn hoá +84)
⚑ U2 Xoá khách theo yêu cầu (Nghị định 13/2023) = ẩn danh hoá, giữ chứng từ tài chính
⚑ U3 Mọi consent ghi rõ mục đích, phiên bản chính sách, thời điểm, IP/UA
U4 Đồng ý marketing tách riêng khỏi đồng ý xử lý đơn hàng
U5 Đổi hạng thành viên phải qua Policy, có ghi vết

16. Context: Loyalty

LoyaltyAccount (aggregate root)
├── customerId, tier, pointsBalance
└── PointsTransaction[]  (append-only: earned | redeemed | expired | adjusted)
# Bất biến
⚑ L1 pointsBalance = Σ PointsTransaction.amount — không lưu số dư rời
⚑ L2 pointsBalance >= 0
⚑ L3 Cộng điểm khi đơn completed, không phải khi paid (tránh cộng điểm rồi khách trả hàng)
⚑ L4 Trả hàng → trừ lại điểm tương ứng; nếu đã tiêu hết → cho phép âm tạm và chặn tiêu tiếp
L5 Điểm có hạn dùng; job hết hạn ghi giao dịch expired

⚑ L1 áp dụng đúng nguyên lý sổ cái như tồn kho và tiền: số dư là kết quả tính, không phải dữ liệu lưu. Nhờ vậy mọi sai lệch đều truy được về giao dịch cụ thể.


17. Context: Review

Loại: Generic · Laravel thuần.

# Bất biến
⚑ V1 Chỉ khách đã mua và đã nhận variant đó mới được đánh giá (chống review giả)
⚑ V2 Một khách đánh giá một orderLineId đúng một lần
V3 Đánh giá qua kiểm duyệt trước khi hiển thị (tự động + thủ công)
V4 Điểm trung bình là materialized view, cập nhật theo sự kiện

18. Context: Procurement

Loại: Supporting · Module: modules/Procurement (P3)

PurchaseOrder (aggregate root)
├── supplierId, lines[] { variantId, quantity, unitCost }
├── status: draft | sent | partially_received | received | closed
└── expectedAt

GoodsReceipt (aggregate root)
├── purchaseOrderId, lines[] { variantId, quantity, batchCode, expiresAt, unitCost }
└── receivedAt

⚑ Bất biến: GoodsReceiptnguồn duy nhất tạo Batch với unitCost. Giá vốn không được nhập tay ở chỗ khác — đây là điều kiện để báo cáo lãi gộp đáng tin.


19. Context chỉ đọc: Search, CMS, Analytics, Notification

Bốn context này không có aggregate và không có bất biến nghiệp vụ. Chúng tiêu thụ domain event và xây read model.

Context Nguồn dữ liệu Ghi chú
Search catalog.*, pricing.price_changed, inventory.stock_* ⚑ Độ trễ chỉ mục < 60 s (SLO — doc 01 §14.3)
CMS Laravel + Filament thuần
Analytics Toàn bộ event P7: CDC → ClickHouse
Notification Toàn bộ event ⚑ Idempotent: một event chỉ gửi một thông báo, kể cả khi job retry

20. Danh mục Domain Event

20.1 Quy ước

Tên: <context>.<aggregate>.<động từ quá khứ> — chữ thường, snake_case. Ví dụ order.placed, inventory.stock_reserved.

Cấu trúc chuẩn:

{
  "event_id":      "uuidv7",
  "event_type":    "order.placed",
  "event_version": 1,
  "occurred_at":   "2026-08-13T09:12:33.482Z",
  "aggregate_type":"order",
  "aggregate_id":  "01920e...",
  "actor":         { "type": "customer|admin|system", "id": "..." },
  "trace_id":      "...",
  "payload":       { }
}

trace_id bắt buộc — đây là thứ nối trace OpenTelemetry xuyên qua queue (doc 01 §14.2).

20.2 Ba nguyên tắc thiết kế payload

  1. Đủ để người nghe không phải hỏi lại. order.placed chứa đủ dòng hàng và tổng tiền để Notification gửi được email mà không cần gọi Order.
  2. Không chứa dữ liệu nhạy cảm. Không số thẻ, không mật khẩu, không CCCD. Event nằm trong log và có thể được sao chép nhiều nơi.
  3. Chỉ thêm, không xoá/đổi nghĩa trường. Đổi phá vỡ → tăng event_version và viết upcaster.

20.3 Bảng sự kiện đầy đủ

Sự kiện Payload chính Người nghe
catalog.product.published productId, sku[], categoryIds Search, Cache, Analytics
catalog.product.unpublished productId Search, Cache, Cart (đánh dấu dòng)
catalog.variant.archived variantId Search, Cart, Inventory
pricing.price_changed variantId, oldPrice, newPrice, effectiveAt Search, Cache, Analytics
inventory.stock_reserved variantId, warehouseId, qty, reservationId, reference Checkout
inventory.stock_released variantId, warehouseId, qty, reason Search (nếu về lại còn hàng)
inventory.stock_deducted variantId, warehouseId, qty, batchAllocations[] Analytics, Procurement
inventory.reservation_expired reservationId, reference Checkout, Cart
inventory.low_stock_detected variantId, available, threshold Notification, Procurement
inventory.batch_expiring_soon batchId, expiresAt, quantity Notification, Promotion (xả hàng)
cart.abandoned cartId, customerId?, lines[], value Notification, Analytics
checkout.completed sessionId, orderId
order.placed orderId, orderNumber, customerId?, lines[], grandTotal, paymentMethod Notification, Analytics, Inventory
order.confirmed orderId, confirmedAt Fulfilment, Notification, Promotion (chốt lượt coupon)
order.cancelled orderId, reason, refundRequired Inventory, Promotion, Payment, Notification
order.shipped orderId, shipmentIds[], trackingNumbers[] Notification, Tax (phát hành hoá đơn)
order.delivered orderId, deliveredAt Payment (COD), Notification
order.completed orderId Loyalty, Review (mở đánh giá), Analytics
order.returned orderId, returnRequestId Loyalty (trừ điểm), Analytics
payment.captured orderId, amount, gateway, gatewayTxnId Order, Ledger, Notification
payment.failed orderId, gateway, errorCode Order, Notification, Analytics (cảnh báo card testing)
payment.refunded orderId, amount, reason Order, Ledger, Notification
payment.cod_collected orderId, amount, carrier Ledger
payment.reconciliation_mismatch date, carrier/gateway, expected, actual, delta Cảnh báo P0
fulfilment.shipment_created shipmentId, orderId, carrier, trackingNumber Order, Notification
fulfilment.delivery_failed shipmentId, attemptCount, reason Notification, CSKH
returns.requested returnRequestId, orderId, lines[] Notification, CSKH
returns.received returnRequestId, restockDecision Inventory, Payment
tax.invoice_issued invoiceId, orderId, invoiceNumber, taxAuthorityCode Notification, Analytics
promotion.coupon_redeemed couponId, customerId, orderId, discountAmount Analytics
loyalty.points_earned customerId, points, orderId Notification

⭐ Sự kiện có ≥ 3 người nghe (order.cancelled, payment.captured, order.completed) là những sự kiện cần thiết kế payload kỹ nhất và cần test hợp đồng riêng.

20.4 Phiên bản hoá sự kiện

interface EventUpcaster {
    public function supports(string $eventType, int $fromVersion): bool;
    public function upcast(array $payload): array;
}

// Ví dụ: v1 có "total" (số), v2 tách thành "grand_total" (Money)
final class OrderPlacedV1ToV2 implements EventUpcaster
{
    public function supports(string $t, int $v): bool
    {
        return $t === 'order.placed' && $v === 1;
    }

    public function upcast(array $p): array
    {
        $p['grand_total'] = ['amount' => (string) $p['total'], 'currency' => 'VND'];
        unset($p['total']);
        return $p;
    }
}

⏳ Upcaster chỉ thực sự cần từ P8 (Event Sourcing). Nhưng quy ước event_version phải có từ P2 — thêm trường vào sự kiện đã lưu hàng triệu bản ghi là việc rất tốn kém.


21. Read model

Aggregate được tối ưu cho ghi đúng. Trang danh sách sản phẩm cần đọc nhanh. Đây là hai mục tiêu xung đột, và câu trả lời là read model riêng — không phải gộp aggregate.

Read model Nguồn Cách dựng Pha
product_listing (product + giá + tồn + rating) catalog.*, pricing.*, inventory.*, review Materialized view, refresh theo event P1
order_summary cho admin order.*, payment.*, fulfilment.* Bảng chiếu, cập nhật theo event P2
customer_360 Nhiều context Bảng chiếu P4
search_index catalog.*, pricing.* PostgreSQL FTS → Typesense (P6) P1
Báo cáo OLAP Toàn bộ CDC → ClickHouse + dbt P7

Bất biến của read model: dựng lại được hoàn toàn từ nguồn. Mất read model = chậm, không = mất dữ liệu (doc 01 §3, nguyên tắc 4). Mỗi read model phải có lệnh rebuild và lệnh đó phải được chạy thử định kỳ.


22. Luồng xuyên context

22.1 Đặt hàng thành công (thanh toán online)

Khách        Checkout         Inventory       Order        Payment      Outbox
  │             │                │              │             │            │
  ├─ checkout ─►│                │              │             │            │
  │             ├─ giải giá + KM + ship + thuế  │             │            │
  │             ├─ chốt hash, expires=+15′      │             │            │
  │             ├─ reserve ─────►│              │             │            │
  │             │◄─ reservationIds (all-or-nothing)           │            │
  │             ├─ tạo đơn ─────────────────────►│            │            │
  │             │                │       [TX] lưu Order + ghi outbox ──────►│
  │             │                │              │             │  order.placed
  │◄─ URL thanh toán ◄───────────────────────────┼─ tạo txn ─►│            │
  │                                              │             │            │
  ├─ thanh toán tại cổng ───────────────────────────────────────────────────►
  │                                              │             │            │
  │                                     webhook ─┼────────────►│            │
  │                              [TX] idempotency + update txn + ledger ────►│
  │                                              │             │  payment.captured
  │                                              │◄─ confirm ──┤            │
  │                                       [TX] status=confirmed + outbox ───►│
  │                                              │             │  order.confirmed
  │                                              │             │            │
  │        ┌──────────────────── người nghe order.confirmed ───────────────┘
  │        ├─► Promotion: chốt tiêu thụ lượt coupon
  │        ├─► Notification: email xác nhận
  │        └─► Fulfilment: sẵn sàng tạo vận đơn

Mỗi [TX] là một transaction riêng, mỗi transaction chạm đúng một aggregate + outbox (nguyên tắc §1.1).

22.2 Huỷ đơn — luồng bù trừ

order.cancelled ──┬─► Inventory : nhả toàn bộ reservation của đơn
                  ├─► Promotion : nhả lượt dùng coupon (redemption_count − 1)
                  ├─► Payment   : nếu paidAmount > 0 → khởi tạo hoàn tiền
                  │               → payment.refunded → order.registerRefund()
                  ├─► Fulfilment: huỷ vận đơn nếu chưa bàn giao
                  │               ⚠ đã bàn giao → KHÔNG huỷ được,
                  │                 chuyển sang luồng "hoàn hàng về kho"
                  └─► Notification: email thông báo huỷ

⚑ Mỗi bước idempotent — nhận order.cancelled hai lần không được nhả chỗ hai lần
   (bảo đảm bằng reservation.status: chỉ 'held' mới nhả được).

22.3 Trả hàng

Khách yêu cầu ─► returns.requested
                 └─► CSKH duyệt ─► returns.approved
                                   └─► khách gửi hàng về
                                       └─► kho nhận + kiểm ─► returns.received
                                            ├─ condition=resellable & lô còn hạn
                                            │  └─► Inventory.receive() + StockMovement
                                            ├─ ngược lại → scrap/quarantine
                                            ├─► Payment: hoàn tiền theo tỷ lệ
                                            │   (đã trừ phần giảm giá phân bổ về dòng — §6.4)
                                            ├─► Loyalty: trừ điểm đã cộng
                                            └─► Tax: hoá đơn điều chỉnh giảm

23. Bảng bất biến toàn hệ thống

Danh sách này là đầu vào trực tiếp cho bộ test. Mỗi dòng phải có ít nhất một test.

Context Bất biến Cưỡng chế ở
I1–I3 Inventory 0 <= reserved <= onHand Domain + CHECK + WHERE nguyên tử
I4 Inventory Movement khớp onHand Job đối chiếu hằng đêm
I5 Inventory Không giữ chỗ trùng UNIQUE CSDL
I7 Inventory Σ lô = onHand Job đối chiếu
I8 Inventory Không xuất lô cận/quá date Domain (FEFO)
O1 Order grandTotal >= 0 và khớp công thức Domain
O2 Order lines bất biến readonly
O3 Order Chuyển trạng thái hợp lệ Enum allowedNext()
O4 Order refunded <= paid Domain
O7 Order Trạng thái cuối không thoát ra Enum
Y1 Payment Sổ kép cân bằng CONSTRAINT TRIGGER DEFERRABLE
Y2 Payment Idempotency webhook PRIMARY KEY + ON CONFLICT
Y4 Payment refund <= capture Domain
Y5 Payment Ledger append-only Không có UPDATE/DELETE trong repository
M1 Promotion Giảm giá không vượt subtotal Domain
M2/M3 Promotion Giới hạn lượt dùng UPDATE nguyên tử + UNIQUE
P1/P3 Pricing sale <= list, không chồng lấn Domain + EXCLUDE constraint
H1/H2 Checkout Giữ chỗ đủ / hết hạn phải nhả Domain + job
C1 Catalog SKU duy nhất UNIQUE
C4 Catalog Không xoá variant đã bán FK + soft archive
R1/R2 Returns Không trả/hoàn quá Domain
R4 Returns Mỹ phẩm đã mở không nhập lại Domain
T1/T2 Tax Hoá đơn bất biến, thuế theo thời điểm Domain
L1/L2 Loyalty Số dư = Σ giao dịch, >= 0 Domain
U1/U2 Customer Định danh duy nhất, xoá = ẩn danh UNIQUE + Policy
V1 Review Chỉ người đã mua Domain

24. Chính sách (Policy) — nơi chứa quy tắc hay đổi

Tách khỏi aggregate vì chúng thay đổi theo quyết định kinh doanh, không theo bản chất nghiệp vụ.

Policy Quyết định Mặc định đề xuất
ReservationTtlPolicy Giữ chỗ bao lâu 15′ (thường) · 5′ (flash sale)
OrderCancellationPolicy Bao lâu tự huỷ đơn chưa thanh toán 30′
ReturnWindowPolicy Hạn đổi trả 7 ngày (thường) · 15 ngày (thành viên VIP)
MinShelfLifePolicy Ngưỡng ngày hạn dùng tối thiểu khi xuất 120 ngày (mỹ phẩm)
CodEligibilityPolicy Ai được COD, hạn mức bao nhiêu ≤ 5.000.000₫ và không có lịch sử từ chối nhận
FreeShippingPolicy Khi nào miễn phí ship Đơn ≥ 500.000₫
LoyaltyEarnPolicy Tỷ lệ tích điểm 1 điểm / 10.000₫, cộng khi completed
TierUpgradePolicy Điều kiện lên hạng Tổng chi tiêu 12 tháng gần nhất
FraudRiskPolicy Chấm điểm rủi ro COD ⏳ P7
InvoiceTimingPolicy Phát hành hoá đơn lúc nào ⚠ Chờ kế toán quyết

⚑ Mọi Policy đều là interface trong Domain/Policy/, hiện thực trong Infrastructure/, cấu hình được, và có test riêng. Không nhúng số vào aggregate.


25. Kiểm thử tầng Domain

Domain layer không phụ thuộc Laravel (§1.5) → test chạy trong mili-giây, không cần database.

it('không cho giữ chỗ vượt quá số khả dụng', function () {
    $stock = new StockItem($variantId, $warehouseId, onHand: 10, reserved: 8, version: 1);

    expect($stock->available)->toBe(2);
    expect(fn () => $stock->reserve(3))->toThrow(InsufficientStock::class);

    $stock->reserve(2);
    expect($stock->available)->toBe(0);
});

it('không cho đơn đã huỷ chuyển sang trạng thái khác', function () {
    expect(OrderStatus::Cancelled->allowedNext())->toBe([]);
    expect(OrderStatus::Cancelled->canTransitionTo(OrderStatus::Confirmed))->toBeFalse();
});

it('không cho hoàn quá số đã thu', function () {
    $order = anOrder()->paid(Money::of('1100000'))->build();

    expect(fn () => $order->registerRefund(Money::of('1200000'), $now))
        ->toThrow(DomainException::class, 'Hoàn vượt quá số đã thu');
});

// Property-based: bất biến phải đúng với MỌI chuỗi thao tác hợp lệ
it('reserved luôn nằm trong [0, onHand] sau mọi chuỗi thao tác', function () {
    $stock = new StockItem($variantId, $warehouseId, 100, 0, 1);

    foreach (randomOperationSequence(200) as $op) {
        try { $op($stock); } catch (DomainException) { /* từ chối là hợp lệ */ }

        expect($stock->reserved)->toBeGreaterThanOrEqual(0);
        expect($stock->reserved)->toBeLessThanOrEqual($stock->onHand);
    }
});

Ba tầng test cho bất biến quan trọng nhất (I3 — không oversell):

  1. Unit — aggregate từ chối trong bộ nhớ (ở trên).
  2. Integration — câu UPDATE nguyên tử trả 0 dòng khi hết hàng, trên PostgreSQL thật.
  3. Concurrency — 100 request song song mua 1 sản phẩm còn 1 cái → đúng 1 thành công (doc 01 §13.1).

Ba tầng vì mỗi tầng bắt một loại lỗi khác nhau, và tầng 3 là tầng duy nhất bắt được lỗi thật sự nguy hiểm.


26. Những chỗ cố ý CHƯA mô hình hoá

Ghi ở đây để không ai tưởng là quên.

Chủ đề Vì sao hoãn Pha
Marketplace đa nhà bán (Seller) Thay đổi ranh giới Order (tách đơn theo nhà bán), Payment (chia tiền), Inventory (kho của nhà bán). Làm sớm sẽ phức tạp hoá mọi thứ P8
B2B: báo giá, công nợ, hợp đồng Mô hình giá và thanh toán khác hẳn B2C P8
Subscription / mua định kỳ Cần mô hình vòng đời riêng P8
Gift card liability trong sổ kép — đã chừa sẵn tài khoản liability.giftcard P6
Đa kênh (Shopee/Lazada/TikTok Shop) Cần cơ chế phân bổ tồn theo kênh P8
Đa tiền tệ thực sự Money đã có currency; cần thêm ExchangeRate snapshot P8
Dropship Thay đổi luồng Fulfilment P8
Event Sourcing cho Inventory/Payment Mô hình hiện tại (append-only movement + ledger) đã cho audit trail đầy đủ mà không có chi phí ES (doc 01 §16) P8

⚑ Tất cả những mục trên đều không đòi hỏi phá vỡ mô hình hiện tại — chúng thêm context mới hoặc thêm trường, không sửa bất biến đã có. Đây là tiêu chí để đánh giá mô hình này có tốt không.


27. Việc tiếp theo

Việc Tài liệu Pha
DDL đầy đủ, ERD, chiến lược index 03-database-schema.md P1
Quy ước tạo module, mẫu code, cấu hình deptrac 04-module-guide.md P0
OpenAPI 3.1 cho các context ở P1 05-api-spec/ P1

Cần xác nhận trước khi code:

  1. Thời điểm phát hành hoá đơn (§14) — quyết định kế toán, cần hỏi người phụ trách.
  2. Hạn đổi trảngưỡng hạn dùng tối thiểu (§24) — quyết định kinh doanh.
  3. Hạn mức COD và tiêu chí khách rủi ro (§24).
  4. Có làm marketplace không — nếu có kế hoạch chắc chắn trong 12 tháng, một vài quyết định ở OrderInventory nên chuẩn bị sẵn từ bây giờ thay vì sửa sau.

Tài liệu #2 · Lập 13/08/2026 · Mã bất biến (⚑) trong tài liệu này được dùng làm định danh test — không đổi mã khi sửa nội dung.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.