# 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 by và reason |
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ợ | Có |
|---|---|---|
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ợ | Có |
|---|---|---|
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ợ | Có |
|---|---|---|
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ợ | Có |
|---|---|---|
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 và 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: GoodsReceipt là nguồ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
- Đủ để người nghe không phải hỏi lại.
order.placedchứa đủ dòng hàng và tổng tiền đểNotificationgửi được email mà không cần gọiOrder. - 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.
- Chỉ thêm, không xoá/đổi nghĩa trường. Đổi phá vỡ → tăng
event_versionvà 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.
| Mã | 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):
- Unit — aggregate từ chối trong bộ nhớ (ở trên).
- Integration — câu
UPDATEnguyên tử trả 0 dòng khi hết hàng, trên PostgreSQL thật. - 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 | Là 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:
- 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.
- Hạn đổi trả và ngưỡng hạn dùng tối thiểu (§24) — quyết định kinh doanh.
- Hạn mức COD và tiêu chí khách rủi ro (§24).
- 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 ở
OrdervàInventorynê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