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 đã chốt (PROCESSING → COMPLETED) - loại bỏ nhu cầu về hai 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ụ phía sau. 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 → đi theo nhánh PV đơn lẻ tiêu chuẩn. Không cần tra cứu category |
| 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, thao tác thêm vào giỏ sẽ bung bộ gói thành các dòng con. Chi tiết:
| Khía cạnh | Chi tiết |
|---|---|
| Dòng lead | Một SaleOrderItem chứa combo PV; mang đầy đủ giá; leadItemId = null |
| Các dòng con | N dòng SaleOrderItem do BundleExpansionService.extractComboItems tạo. Mỗi dòng: leadItemId = lead.id, unitPrice = 0, basePrice = 0, tax = 0, metadata.combo = { leadVariantId, bundlerRowIds } |
| Reservation | Mỗi dòng con một lần applyReservationDelta, chạy song song có giới hạn (executePromiseWithLimit, limit=10). Dòng lead của combo không giữ chỗ gì - PV COMBO không có InventoryItem (không nằm trong STOCKABLE_SET nên CDC không bao giờ seed) |
| Tính liền mạch | Tất cả reservation + insert dùng chung transaction của caller. Bất kỳ dòng con nào gặp STOCK_UNAVAILABLE đều rollback toàn bộ lần thêm combo |
| Guard thêm lại | Thêm lại cùng một combo sẽ ném COMBO_ALREADY_IN_ORDER. Dùng endpoint sửa để đổi số lượng |
| Combo lồng nhau | extractComboItems đệ quy qua các related variant có type là COMBO, kèm giới hạn độ sâu (MAX=5) + guard chống vòng lặp. Các dòng lá được gộp theo relatedVariantId |
| Bộ gói rỗng | COMBO_HAS_NO_COMPONENTS - cấu hình sai trong danh mục lộ ra ngay lần bán đầu tiên |
| Chỉnh sửa | Chỉ qua lead - xem order-operations.md. Sửa dòng con trực tiếp bị từ chối 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 |
| Payment Webhooks | Định tuyến PaymentWebhookService, handler SaleOrder/SaleCheck, phát Kafka |
| 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 |