Sale Order
1. Tổng quan
SaleOrder là thực thể trung tâm của Sale Service. Nó phục vụ mục đích kép — giỏ hàng (DRAFT) và đơn hàng đã cam kết (PROCESSING → COMPLETED) — loại bỏ nhu cầu về thực thể Cart và Order riêng biệt.
Mã nguồn: Schema thực thể định nghĩa trong @nx/core (packages/core/src/models/schemas/sale/).
2. Sơ đồ quan hệ thực thể
3. Trường SaleOrder
Định danh
| Trường | Kiểu | Mô tả |
|---|---|---|
id | text | Snowflake ID tạo bởi IdGenerator.getInstance() |
orderNumber | text | Định dạng: YYYYMMDDHHmmss-<snowflakeId> (vd: 20250128143025-8a7b6c5d) |
name | text | Tên hiển thị — mặc định là orderNumber nếu không cung cấp |
slug | text | Định danh thân thiện URL: SaleOrder-<orderNumber> |
Tạo số đơn hàng (SaleOrderService.createDraftOrder):
const orderNumber = [dayjs().format('YYYYMMDDHHmmss'), this.idGenerator.nextId()].join('-');
const slug = [SaleOrder.name, orderNumber].join('-');Quan hệ
| Trường | Kiểu | Mô tả |
|---|---|---|
saleChannelId | text FK | Kênh bán hàng đơn hàng thuộc về |
merchantId | text FK | Lấy từ merchantId của kênh bán hàng khi tạo |
createdBy | text FK | Người dùng tạo đơn hàng |
modifiedBy | text FK | Người dùng sửa đổi đơn hàng cuối cùng |
Tài chính
| Trường | Kiểu | Mô tả |
|---|---|---|
currency | text | Mã ISO 4217 (mặc định: VND). Cố định khi tạo |
exchangeRate | numeric | Tỷ giá hối đoái nếu cần chuyển đổi tiền tệ |
subtotal | numeric | Tổng tất cả mặt hàng (duy trì bởi updateSummaryFromItems) |
tax | numeric | Tổng thuế tất cả mặt hàng |
discount | numeric | Tổng chiết khấu tất cả mặt hàng |
total | numeric | subtotal - discount + tax (tối thiểu 0) |
Trạng thái & Theo dõi
| Trường | Kiểu | Mô tả |
|---|---|---|
status | text | Mã trạng thái hiện tại (xem vòng đời bên dưới) |
counter | jsonb | Tiến trình thanh toán: { total, paid, paidItemIds } |
validity | jsonb | Khoảng ngày tùy chọn: { from, to } |
metadata | jsonb | Ngữ cảnh checkout: { merchantId, note?, finance } (đặt khi checkout) |
checkSplitAt | timestamp | Đặt khi tạo check (tách), xóa khi rollback. Ngăn tách trùng lặp |
orderSplitAt | timestamp | Đặt khi đơn hàng được tách thành nhiều đơn. Thông tin -- không bao giờ xóa |
cancellationReason | text | Chuỗi lý do khi hủy |
Metadata
Trường metadata JSONB lưu ngữ cảnh checkout cho các dịch vụ downstream. Nó được khởi tạo khi tạo draft với cài đặt finance mặc định và cập nhật khi checkout:
| Thuộc tính | Kiểu | Đặt tại | Mô tả |
|---|---|---|---|
merchantId | string | Tạo draft | Tham chiếu merchant (từ kênh bán hàng) |
finance.use | boolean | Checkout | Có ghi nhận thu nhập vào module Finance không |
finance.walletId | string? | Checkout | ID ví Finance (khi use: true) |
finance.categoryId | string? | Checkout | ID danh mục Finance (khi use: true) |
note | string? | Checkout | Ghi chú checkout tùy chọn |
Dấu thời gian
| Trường | Đặt khi |
|---|---|
draftAt | Tạo đơn hàng (DRAFT) |
processingAt | Checkout (PROCESSING) |
partialAt | Thanh toán một phần đầu tiên (PARTIAL) |
completedAt | Thanh toán đầy đủ (COMPLETED) |
cancelledAt | Hủy (CANCELLED) |
createdAt | Bản ghi được tạo |
modifiedAt | Bản ghi sửa đổi cuối |
deletedAt | Xóa mềm (null nếu đang hoạt động) |
4. Vòng đời trạng thái
Mã trạng thái
| Trạng thái | Mã | Giai đoạn | Mô tả |
|---|---|---|---|
| DRAFT | 001_DRAFT | Giỏ hàng | Giỏ hàng — có thể thêm/xóa/sửa mặt hàng |
| PROCESSING | 203_PROCESSING | Thanh toán | Đã checkout, đang chờ xác nhận thanh toán |
| PARTIAL | 300_PARTIAL | Thanh toán | Đã nhận một phần thanh toán, đang chờ phần còn lại |
| COMPLETED | 303_COMPLETED | Cuối cùng | Đã thanh toán đầy đủ, đơn hàng hoàn thành |
| CANCELLED | 505_CANCELLED | Cuối cùng | Đơn hàng bị chấm dứt |
State Machine
Quy tắc chuyển đổi
| Từ | Đến | Kích hoạt | Service Method | Xác thực |
|---|---|---|---|---|
| (mới) | DRAFT | Tạo đơn hàng | SaleOrderService.createDraftOrder() | Yêu cầu saleChannelId hợp lệ |
| DRAFT | PROCESSING | Checkout | CheckoutService.checkout() | Giỏ không rỗng, giá không âm, số lượng ≥ 1 |
| DRAFT | CANCELLED | Hủy | SaleOrderService.cancelOrder() | Không phải trạng thái cuối |
| PROCESSING | DRAFT | Hoàn tác | CheckoutService.revertCheckout() | SaleOrderStatuses.canRevertToCart() trả về true |
| PROCESSING | PARTIAL | Thanh toán thành công | PaymentWebhookService.handlePaymentSuccess() | paid < total |
| PROCESSING | COMPLETED | Thanh toán thành công | PaymentWebhookService.handlePaymentSuccess() | paid >= total |
| PROCESSING | CANCELLED | Thất bại/hết hạn/hủy | PaymentWebhookService.handlePayment*() | Trạng thái phải là PROCESSING |
| PROCESSING | PARTIAL | Webhook thanh toán check | PaymentWebhookService.checkOrderCompletionViaChecks() | 1+ check PAID, chưa hết |
| PROCESSING | COMPLETED | Webhook thanh toán check (tất cả check PAID) | PaymentWebhookService.checkOrderCompletionViaChecks() | Tất cả check trên đơn hàng PAID |
| PARTIAL | COMPLETED | Thanh toán thành công | PaymentWebhookService.handlePaymentSuccess() | paid >= total |
| PARTIAL | COMPLETED | Webhook thanh toán check (các check còn lại PAID) | PaymentWebhookService.checkOrderCompletionViaChecks() | Tất cả check trên đơn hàng PAID |
| PARTIAL | CANCELLED | Hủy | SaleOrderService.cancelOrder() | Không phải trạng thái cuối |
5. Trường SaleOrderItem
Định danh & Tham chiếu
| Trường | Kiểu | Mô tả |
|---|---|---|
id | text | Snowflake ID |
saleOrderId | text FK | Đơn hàng cha |
mode | text | 000_PRODUCT hoặc 100_CUSTOM (từ SaleOrderItemModes) |
itemType | text | Loại tham chiếu (vd: ProductVariant, CustomProductVariant) |
itemId | text | Tham chiếu bên ngoài (ID biến thể sản phẩm, hoặc CPV_<uuid> cho custom) |
leadItemId | text | ID mặt hàng gốc nếu đây là mặt hàng liên kết |
Định giá
| Trường | Kiểu | Mô tả |
|---|---|---|
currency | text | Tiền tệ mặt hàng |
basePrice | numeric | Giá gốc trước chiết khấu |
unitPrice | numeric | Giá cuối mỗi đơn vị |
discount | numeric | Số tiền chiết khấu |
quantity | numeric | Số đơn vị (lưu string trong DB, parse thành number) |
tax | numeric | Số tiền thuế đã tính |
total | numeric | Tính bởi updateSummaryFromItems() |
Nguồn định giá
| Trường | Kiểu | Mô tả |
|---|---|---|
transferHistory | jsonb | Mảng các bản ghi { sourceOrderId, targetOrderId, transferredAt } theo dõi chuỗi gộp. null = mặt hàng gốc |
fareId | text | Tham chiếu đến pricing fare |
fareProvider | text | Nhà cung cấp định giá |
priceMetadata | jsonb | Chứa object fareSource đầy đủ |
metadata | jsonb | Snapshot sản phẩm (chế độ PRODUCT: tự động điền phía server) hoặc chi tiết mặt hàng tùy chỉnh (chế độ CUSTOM: do client cung cấp) |
Snapshot Metadata Sản phẩm
Đối với mặt hàng chế độ PRODUCT, trường metadata được tự động điền phía server tại thời điểm thêm vào giỏ bởi ProductVariantSnapshotService. Điều này ghi lại snapshot tại thời điểm của dữ liệu ProductVariant, đảm bảo độ chính xác lịch sử ngay cả khi sản phẩm bị sửa đổi sau này.
Tham chiếu FK (itemId/itemType) vẫn được giữ để đảm bảo tính toàn vẹn tham chiếu, nhưng metadata là nguồn sự thật cho những gì đã bán.
| Trường Snapshot | Nguồn | Mô tả |
|---|---|---|
name | ProductInfo.name | Tên sản phẩm i18n { default, en?, vi? } |
description | ProductInfo.description | Mô tả sản phẩm (ngôn ngữ mặc định) |
sku | ProductIdentifier (scheme=SKU) | Đơn vị quản lý kho |
barcode | ProductIdentifier (scheme=BARCODE) | Giá trị barcode |
imageUrl | MetaLink.link | Hình ảnh sản phẩm đầu tiên (mới nhất) |
externalId | ProductVariant.identifier | Định danh PV_YYYYMMDD_xxx |
externalSource | 'ProductVariant' | Hằng số, đánh dấu đây là snapshot phía server |
Lưu ý: Đối với mặt hàng chế độ CUSTOM,
metadatatiếp tục sử dụngproductMetadatado client cung cấp. Mặt hàng custom không có ProductVariant thực để snapshot.
6. Chế độ mặt hàng
Chế độ Product (000_PRODUCT)
Mã nguồn: SaleOrderService._addProductItem() (dòng 295–350)
| Hành vi | Thực thi |
|---|---|
| Tham chiếu | Liên kết đến ProductVariant hiện có qua itemId + itemType |
| Phân nhánh theo type | ProductVariantTypes.isCombo(variant.type) → _addComboProductItem (xem bên dưới). Còn lại → path PV đơn lẻ tiêu chuẩn. Không cần category lookup |
| Snapshot metadata | Snapshot phía server của dữ liệu ProductVariant (tên, SKU, barcode, hình ảnh) qua ProductVariantSnapshotService.buildSnapshot(). productMetadata do client cung cấp sẽ bị bỏ qua |
| Định giá | fareSource từ FareSourceSchema (gồm fareId, unitPrice, basePrice, tax) |
| Gộp trùng | Truy vấn mặt hàng hiện có theo {saleOrderId, itemId, itemType}. Nếu tìm thấy, tăng số lượng, cập nhật giá, và làm mới snapshot metadata. Nếu không, tạo mặt hàng mới với snapshot |
| Tồn kho | Kết nối theo dõi tồn kho qua Inventory Service |
| Khóa hàng | Đơn hàng cha được khóa qua SELECT ... FOR UPDATE (findWithItemCount với doLock: true) để ngăn race condition khi nhiều request đồng thời thêm mặt hàng |
Sub-mode COMBO (_addComboProductItem)
Khi variant.type là COMBO, cart-add bung bundler thành các row con. Chi tiết:
| Khía cạnh | Chi tiết |
|---|---|
| Lead row | Một SaleOrderItem chứa combo PV; full giá; leadItemId = null |
| Children | N row SaleOrderItem do BundleExpansionService.extractComboItems tạo. Mỗi row: leadItemId = lead.id, unitPrice = 0, basePrice = 0, tax = 0, metadata.combo = { leadVariantId, bundlerRowIds } |
| Reservation | Mỗi leaf một lần applyReservationDelta, parallel có giới hạn (executePromiseWithLimit, limit=10). Combo lead không reserve gì — PV COMBO không có InventoryItem (không nằm trong STOCKABLE_SET nên CDC không bao giờ seed) |
| Atomic | Tất cả reservation + insert chia sẻ transaction caller. Bất kỳ leaf STOCK_UNAVAILABLE rollback toàn bộ combo add |
| Re-add guard | Re-add cùng 1 combo throw COMBO_ALREADY_IN_ORDER. Dùng endpoint edit để đổi qty |
| Combo lồng nhau | extractComboItems đệ quy qua các related variant có type là COMBO, với depth (MAX=5) + cycle guard. Leaves được aggregate theo relatedVariantId |
| Bundler rỗng | COMBO_HAS_NO_COMPONENTS — catalog misconfiguration phơi bày tại lần bán đầu tiên |
| Sửa | Lead-driven only — xem order-operations.md. Sửa child trực tiếp bị reject với COMBO_CHILD_EDIT_FORBIDDEN |
ADR: developer/packages/inventory/decisions/0006-combo-explosion-at-cart-add.
// Phát hiện trùng — nếu cùng sản phẩm tồn tại, gộp số lượng
const currentItem = await this._saleOrderItemRepository.findOne({
filter: { where: { saleOrderId: saleOrder.id, itemId, itemType } },
options: { transaction: tx },
});
if (currentItem) {
const newQuantity = Number(currentItem.quantity) + quantity;
// Cập nhật mặt hàng hiện có với số lượng đã gộp
}Chế độ Custom (100_CUSTOM)
Mã nguồn: SaleOrderService._addCustomItem() (dòng 352–378)
| Hành vi | Thực thi |
|---|---|
| Tham chiếu | Tự tạo: CPV_<crypto.randomUUID()> |
| Loại mặt hàng | Luôn là CustomProductVariant |
| Định giá | fareSource từ FareSourceManualSchema (giá thủ công unitPrice, basePrice, tax tùy chọn) |
| Không gộp | Mỗi lần thêm tạo dòng mới (không phát hiện trùng) |
| Tồn kho | Không theo dõi |
| Khóa hàng | Cùng khóa pessimistic như chế độ PRODUCT |
const { data: orderItem } = await this._saleOrderItemRepository.create({
data: {
saleOrderId: saleOrder.id,
mode: SaleOrderItemModes.CUSTOM,
itemId: `CPV_${crypto.randomUUID()}`,
itemType: 'CustomProductVariant',
// ...
},
});7. Tính thuế
Mã nguồn: SaleOrderService._calculateTax() (dòng 377–406)
Thuế được tính theo từng mặt hàng dựa trên fareSource.tax:
| Chế độ | Công thức | Ví dụ |
|---|---|---|
AMOUNT | Số tiền cố định: value | tax = 10000 (10.000 VND cố định) |
PERCENTAGE | unitPrice × quantity × (value / 100) | 50000 × 2 × (10 / 100) = 10000 |
| (không có) | 0 | Không cấu hình thuế |
private _calculateTax(opts: {
fareSource: { tax?: { mode: 'AMOUNT' | 'PERCENTAGE'; value: number }; unitPrice: number };
quantity: number;
}) {
if (!fareSource.tax) { return 0; }
switch (mode) {
case 'AMOUNT': return value;
case 'PERCENTAGE': return fareSource.unitPrice * quantity * (value / 100);
}
}8. Schema yêu cầu
Mã nguồn: src/models/requests/sale.model.ts
CreateDraftOrderRequest
const CreateDraftOrderRequestSchema = z.object({
saleChannelId: z.string().min(1).max(255),
name: z.string().min(1).max(255).optional(),
currency: z.string().min(3).max(3).default('VND').optional(),
validity: z.object({ from: z.string(), to: z.string() }).optional(),
});AddItemRequest (Discriminated Union)
const AddItemRequestSchema = z.discriminatedUnion('mode', [
// Chế độ PRODUCT — yêu cầu itemType, itemId, FareSourceSchema
AddItemProductRequestSchema,
// Chế độ CUSTOM — chỉ yêu cầu FareSourceManualSchema
AddItemCustomRequestSchema,
]);| Trường | Chế độ Product | Chế độ Custom |
|---|---|---|
mode | 000_PRODUCT (literal) | 100_CUSTOM (literal) |
quantity | 1–9999 (mặc định: 1) | 1–9999 (mặc định: 1) |
itemType | Bắt buộc (mặc định: ProductVariant) | Không áp dụng |
itemId | Bắt buộc (ID biến thể sản phẩm) | Không áp dụng (tự tạo) |
fareSource | FareSourceSchema (giá hệ thống) | FareSourceManualSchema (giá thủ công) |
productMetadata | Bị bỏ qua (snapshot phía server) | Tùy chọn (do client cung cấp) |
CheckoutRequest / CancelOrderRequest
const CheckoutRequestSchema = z.object({
note: z.string().max(1000).optional(),
finance: z.object({ use: z.literal(false) })
.or(z.object({
use: z.literal(true),
walletId: z.string(),
categoryId: z.string(),
})),
});
const CancelOrderRequestSchema = z.object({
reason: z.string().min(1).max(500).optional(),
});Trường finance là bắt buộc và xác định liệu module Finance có ghi nhận thu nhập khi thanh toán thành công:
| Biến thể | Mục đích |
|---|---|
{ use: false } | Không ghi nhận tài chính |
{ use: true, walletId, categoryId } | Ghi nhận thu nhập vào ví và danh mục chỉ định |
9. Payment Counter
Trường counter JSONB theo dõi tiến trình thanh toán:
| Thuộc tính | Kiểu | Mô tả |
|---|---|---|
total | number | Tổng số tiền phải trả |
paid | number | Số tiền đã trả |
paidItemIds | string[] | Danh sách ID mặt hàng đã thanh toán |
Giá trị khởi tạo (đặt trong createDraftOrder):
counter: { total: 0, paid: 0, paidItemIds: [] },
metadata: {
merchantId: saleChannel.merchantId,
finance: { use: false },
}10. Ràng buộc nghiệp vụ
Mã nguồn: src/common/constants.ts
| Ràng buộc | Giá trị | Thực thi bởi |
|---|---|---|
MAX_QUANTITY_PER_ITEM | 9999 | SaleOrderItemService._validateQuantity() |
MAX_NUMBER_OF_ITEMS_IN_ORDER | 100 | SaleOrderService.addSaleOrderItem() |
| Khóa tiền tệ | Cố định khi tạo | CreateDraftOrderRequestSchema |
| Khóa chỉnh sửa | Chỉ DRAFT | SaleOrderStatuses.canModifyItems() |
| Khóa hủy | Không từ trạng thái cuối | SaleOrderStatuses.isTerminal() |
| Khóa hoàn tác | Chỉ PROCESSING | SaleOrderStatuses.canRevertToCart() |
Số lượng ≤ 0 nghĩa là xóa mềm — SaleOrderItemService._buildUpdateData() đặt deletedAt khi số lượng là 0 hoặc âm:
private _buildUpdateData(data: TUpdateItemData): Partial<TSaleOrderItem> {
if (data.quantity !== undefined && data.quantity <= 0) {
return { deletedAt: new Date() };
}
return { quantity: String(data.quantity) };
}11. Tài liệu liên quan
| Tài liệu | Mô tả |
|---|---|
| Luồng Checkout | Xác thực CheckoutService, DRAFT→PROCESSING, logic hoàn tác |
| Tích hợp Thanh toán | Handler sự kiện PaymentWebhookService, BullMQ dispatch |
| Tách Check | Hệ thống SaleCheck cho tách hóa đơn, thao tác check, thanh toán check |
| Thao tác Đơn hàng | Gộp đơn hàng, rollback gộp, tách đơn hàng với lịch sử chuyển |
| Tổng quan Dịch vụ Sale | Kiến trúc, component, binding key |