Skip to content

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ườngKiểuMô tả
idtextSnowflake ID tạo bởi IdGenerator.getInstance()
orderNumbertextĐịnh dạng: YYYYMMDDHHmmss-<snowflakeId> (vd: 20250128143025-8a7b6c5d)
nametextTên hiển thị — mặc định là orderNumber nếu không cung cấp
slugtextĐịnh danh thân thiện URL: SaleOrder-<orderNumber>

Tạo số đơn hàng (SaleOrderService.createDraftOrder):

typescript
const orderNumber = [dayjs().format('YYYYMMDDHHmmss'), this.idGenerator.nextId()].join('-');
const slug = [SaleOrder.name, orderNumber].join('-');

Quan hệ

TrườngKiểuMô tả
saleChannelIdtext FKKênh bán hàng đơn hàng thuộc về
merchantIdtext FKLấy từ merchantId của kênh bán hàng khi tạo
createdBytext FKNgười dùng tạo đơn hàng
modifiedBytext FKNgười dùng sửa đổi đơn hàng cuối cùng

Tài chính

TrườngKiểuMô tả
currencytextMã ISO 4217 (mặc định: VND). Cố định khi tạo
exchangeRatenumericTỷ giá hối đoái nếu cần chuyển đổi tiền tệ
subtotalnumericTổng tất cả mặt hàng (duy trì bởi updateSummaryFromItems)
taxnumericTổng thuế tất cả mặt hàng
discountnumericTổng chiết khấu tất cả mặt hàng
totalnumericsubtotal - discount + tax (tối thiểu 0)

Trạng thái & Theo dõi

TrườngKiểuMô tả
statustextMã trạng thái hiện tại (xem vòng đời bên dưới)
counterjsonbTiến trình thanh toán: { total, paid, paidItemIds }
validityjsonbKhoảng ngày tùy chọn: { from, to }
metadatajsonbNgữ cảnh checkout: { merchantId, note?, finance } (đặt khi checkout)
checkSplitAttimestampĐặt khi tạo check (tách), xóa khi rollback. Ngăn tách trùng lặp
orderSplitAttimestampĐặt khi đơn hàng được tách thành nhiều đơn. Thông tin -- không bao giờ xóa
cancellationReasontextChuỗ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ínhKiểuĐặt tạiMô tả
merchantIdstringTạo draftTham chiếu merchant (từ kênh bán hàng)
finance.usebooleanCheckoutCó ghi nhận thu nhập vào module Finance không
finance.walletIdstring?CheckoutID ví Finance (khi use: true)
finance.categoryIdstring?CheckoutID danh mục Finance (khi use: true)
notestring?CheckoutGhi chú checkout tùy chọn

Dấu thời gian

TrườngĐặt khi
draftAtTạo đơn hàng (DRAFT)
processingAtCheckout (PROCESSING)
partialAtThanh toán một phần đầu tiên (PARTIAL)
completedAtThanh toán đầy đủ (COMPLETED)
cancelledAtHủy (CANCELLED)
createdAtBản ghi được tạo
modifiedAtBản ghi sửa đổi cuối
deletedAtXó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áiGiai đoạnMô tả
DRAFT001_DRAFTGiỏ hàngGiỏ hàng — có thể thêm/xóa/sửa mặt hàng
PROCESSING203_PROCESSINGThanh toánĐã checkout, đang chờ xác nhận thanh toán
PARTIAL300_PARTIALThanh toánĐã nhận một phần thanh toán, đang chờ phần còn lại
COMPLETED303_COMPLETEDCuối cùngĐã thanh toán đầy đủ, đơn hàng hoàn thành
CANCELLED505_CANCELLEDCuối cùngĐơn hàng bị chấm dứt

State Machine

Quy tắc chuyển đổi

TừĐếnKích hoạtService MethodXác thực
(mới)DRAFTTạo đơn hàngSaleOrderService.createDraftOrder()Yêu cầu saleChannelId hợp lệ
DRAFTPROCESSINGCheckoutCheckoutService.checkout()Giỏ không rỗng, giá không âm, số lượng ≥ 1
DRAFTCANCELLEDHủySaleOrderService.cancelOrder()Không phải trạng thái cuối
PROCESSINGDRAFTHoàn tácCheckoutService.revertCheckout()SaleOrderStatuses.canRevertToCart() trả về true
PROCESSINGPARTIALThanh toán thành côngPaymentWebhookService.handlePaymentSuccess()paid < total
PROCESSINGCOMPLETEDThanh toán thành côngPaymentWebhookService.handlePaymentSuccess()paid >= total
PROCESSINGCANCELLEDThất bại/hết hạn/hủyPaymentWebhookService.handlePayment*()Trạng thái phải là PROCESSING
PROCESSINGPARTIALWebhook thanh toán checkPaymentWebhookService.checkOrderCompletionViaChecks()1+ check PAID, chưa hết
PROCESSINGCOMPLETEDWebhook thanh toán check (tất cả check PAID)PaymentWebhookService.checkOrderCompletionViaChecks()Tất cả check trên đơn hàng PAID
PARTIALCOMPLETEDThanh toán thành côngPaymentWebhookService.handlePaymentSuccess()paid >= total
PARTIALCOMPLETEDWebhook thanh toán check (các check còn lại PAID)PaymentWebhookService.checkOrderCompletionViaChecks()Tất cả check trên đơn hàng PAID
PARTIALCANCELLEDHủySaleOrderService.cancelOrder()Không phải trạng thái cuối

5. Trường SaleOrderItem

Định danh & Tham chiếu

TrườngKiểuMô tả
idtextSnowflake ID
saleOrderIdtext FKĐơn hàng cha
modetext000_PRODUCT hoặc 100_CUSTOM (từ SaleOrderItemModes)
itemTypetextLoại tham chiếu (vd: ProductVariant, CustomProductVariant)
itemIdtextTham chiếu bên ngoài (ID biến thể sản phẩm, hoặc CPV_<uuid> cho custom)
leadItemIdtextID mặt hàng gốc nếu đây là mặt hàng liên kết

Định giá

TrườngKiểuMô tả
currencytextTiền tệ mặt hàng
basePricenumericGiá gốc trước chiết khấu
unitPricenumericGiá cuối mỗi đơn vị
discountnumericSố tiền chiết khấu
quantitynumericSố đơn vị (lưu string trong DB, parse thành number)
taxnumericSố tiền thuế đã tính
totalnumericTính bởi updateSummaryFromItems()

Nguồn định giá

TrườngKiểuMô tả
transferHistoryjsonbMảng các bản ghi { sourceOrderId, targetOrderId, transferredAt } theo dõi chuỗi gộp. null = mặt hàng gốc
fareIdtextTham chiếu đến pricing fare
fareProvidertextNhà cung cấp định giá
priceMetadatajsonbChứa object fareSource đầy đủ
metadatajsonbSnapshot 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 metadatanguồn sự thật cho những gì đã bán.

Trường SnapshotNguồnMô tả
nameProductInfo.nameTên sản phẩm i18n { default, en?, vi? }
descriptionProductInfo.descriptionMô tả sản phẩm (ngôn ngữ mặc định)
skuProductIdentifier (scheme=SKU)Đơn vị quản lý kho
barcodeProductIdentifier (scheme=BARCODE)Giá trị barcode
imageUrlMetaLink.linkHình ảnh sản phẩm đầu tiên (mới nhất)
externalIdProductVariant.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, metadata tiếp tục sử dụng productMetadata do 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 viThực thi
Tham chiếuLiên kết đến ProductVariant hiện có qua itemId + itemType
Phân nhánh theo typeProductVariantTypes.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 metadataSnapshot 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ùngTruy 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 khoKế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.typeCOMBO, cart-add bung bundler thành các row con. Chi tiết:

Khía cạnhChi tiết
Lead rowMột SaleOrderItem chứa combo PV; full giá; leadItemId = null
ChildrenN row SaleOrderItem do BundleExpansionService.extractComboItems tạo. Mỗi row: leadItemId = lead.id, unitPrice = 0, basePrice = 0, tax = 0, metadata.combo = { leadVariantId, bundlerRowIds }
ReservationMỗ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)
AtomicTất cả reservation + insert chia sẻ transaction caller. Bất kỳ leaf STOCK_UNAVAILABLE rollback toàn bộ combo add
Re-add guardRe-add cùng 1 combo throw COMBO_ALREADY_IN_ORDER. Dùng endpoint edit để đổi qty
Combo lồng nhauextractComboItems đệ quy qua các related variant có typeCOMBO, với depth (MAX=5) + cycle guard. Leaves được aggregate theo relatedVariantId
Bundler rỗngCOMBO_HAS_NO_COMPONENTS — catalog misconfiguration phơi bày tại lần bán đầu tiên
SửaLead-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.

typescript
// 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 viThực thi
Tham chiếuTự tạo: CPV_<crypto.randomUUID()>
Loại mặt hàngLuôn là CustomProductVariant
Định giáfareSource từ FareSourceManualSchema (giá thủ công unitPrice, basePrice, tax tùy chọn)
Không gộpMỗi lần thêm tạo dòng mới (không phát hiện trùng)
Tồn khoKhông theo dõi
Khóa hàngCùng khóa pessimistic như chế độ PRODUCT
typescript
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ứcVí dụ
AMOUNTSố tiền cố định: valuetax = 10000 (10.000 VND cố định)
PERCENTAGEunitPrice × quantity × (value / 100)50000 × 2 × (10 / 100) = 10000
(không có)0Không cấu hình thuế
typescript
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

typescript
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)

typescript
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ườngChế độ ProductChế độ Custom
mode000_PRODUCT (literal)100_CUSTOM (literal)
quantity1–9999 (mặc định: 1)1–9999 (mặc định: 1)
itemTypeBắt buộc (mặc định: ProductVariant)Không áp dụng
itemIdBắt buộc (ID biến thể sản phẩm)Không áp dụng (tự tạo)
fareSourceFareSourceSchema (giá hệ thống)FareSourceManualSchema (giá thủ công)
productMetadataBị bỏ qua (snapshot phía server)Tùy chọn (do client cung cấp)

CheckoutRequest / CancelOrderRequest

typescript
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 financebắ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ínhKiểuMô tả
totalnumberTổng số tiền phải trả
paidnumberSố tiền đã trả
paidItemIdsstring[]Danh sách ID mặt hàng đã thanh toán

Giá trị khởi tạo (đặt trong createDraftOrder):

typescript
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ộcGiá trịThực thi bởi
MAX_QUANTITY_PER_ITEM9999SaleOrderItemService._validateQuantity()
MAX_NUMBER_OF_ITEMS_IN_ORDER100SaleOrderService.addSaleOrderItem()
Khóa tiền tệCố định khi tạoCreateDraftOrderRequestSchema
Khóa chỉnh sửaChỉ DRAFTSaleOrderStatuses.canModifyItems()
Khóa hủyKhông từ trạng thái cuốiSaleOrderStatuses.isTerminal()
Khóa hoàn tácChỉ PROCESSINGSaleOrderStatuses.canRevertToCart()

Số lượng ≤ 0 nghĩa là xóa mềmSaleOrderItemService._buildUpdateData() đặt deletedAt khi số lượng là 0 hoặc âm:

typescript
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ệuMô tả
Luồng CheckoutXác thực CheckoutService, DRAFT→PROCESSING, logic hoàn tác
Tích hợp Thanh toánHandler sự kiện PaymentWebhookService, BullMQ dispatch
Tách CheckHệ thống SaleCheck cho tách hóa đơn, thao tác check, thanh toán check
Thao tác Đơn hàngGộ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ụ SaleKiến trúc, component, binding key

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.