Thao tác Đơn hàng (Gộp & Tách)
1. Tổng quan
Sale Service cung cấp ba thao tác cấp đơn hàng để gộp và tách SaleOrder. Các thao tác này khác với tách hóa đơn, vốn tạo các nhóm thanh toán (SaleCheck) trong cùng một đơn hàng.
| Thao tác | Hướng | Mô tả |
|---|---|---|
| Gộp đơn hàng | N -> 1 | Gộp các món từ nhiều đơn nguồn vào một đơn đích |
| Hoàn tác gộp | 1 -> N | Đảo ngược thao tác gộp, khôi phục đơn nguồn và chuyển món về |
| Tách đơn hàng | 1 -> N | Chia một đơn hàng thành nhiều đơn hàng độc lập |
Cả ba thao tác đều sử dụng theo dõi lịch sử chuyển để ghi lại việc di chuyển món giữa các đơn hàng, cho phép kiểm tra toàn bộ quá trình và khả năng hoàn tác.
Mã nguồn: OrderMergeService và OrderSplitService trong packages/sale/src/services/.
Trường bảo vệ
Hai trường timestamp độc lập trên SaleOrder kiểm soát điều kiện thực hiện thao tác:
| Trường | Đặt khi | Xóa khi | Chặn |
|---|---|---|---|
checkSplitAt | Tạo tách hóa đơn | Hoàn tác hóa đơn | Gộp, hoàn tác gộp, tách đơn hàng |
orderSplitAt | Thực hiện tách đơn hàng | Không bao giờ (chỉ mang tính thông tin) | Không chặn gì |
WARNING
Nếu checkSplitAt đã được đặt, thu ngân phải hoàn tác hóa đơn trước khi thực hiện bất kỳ thao tác gộp hoặc tách cấp đơn hàng nào.
2. Lịch sử chuyển
Mỗi SaleOrderItem có một cột JSONB nullable transferHistory theo dõi toàn bộ chuỗi di chuyển giữa các đơn hàng.
Mã nguồn: Kiểu TTransferHistoryEntry trong packages/core/src/models/schemas/sale/sale-item/model.ts.
Định nghĩa kiểu
export type TTransferHistoryEntry = {
sourceOrderId: string; // Order the item was in BEFORE this transfer
targetOrderId: string; // Order the item was transferred TO
transferredAt: string; // ISO timestamp of the transfer
};Quy tắc diễn giải
| Giá trị | Ý nghĩa |
|---|---|
null | Món là gốc của đơn hiện tại (chưa từng được chuyển) |
[entry] | Món đã được chuyển một lần (một lần gộp hoặc tách) |
[entry1, entry2, ...] | Món có toàn bộ chuỗi chuyển (gộp liên tiếp) |
Ví dụ gộp liên tiếp (C -> B -> A)
Bước 1 -- Món gốc của Đơn C, gộp C vào B:
{
"transferHistory": [
{
"sourceOrderId": "C",
"targetOrderId": "B",
"transferredAt": "2026-03-30T01:00:00Z"
}
]
}Bước 2 -- Gộp B vào A. Cùng món đó có thêm một mục mới:
{
"transferHistory": [
{
"sourceOrderId": "C",
"targetOrderId": "B",
"transferredAt": "2026-03-30T01:00:00Z"
},
{
"sourceOrderId": "B",
"targetOrderId": "A",
"transferredAt": "2026-03-30T02:00:00Z"
}
]
}Các đặc điểm chính của mảng:
transferHistory[0].sourceOrderId-- đơn hàng gốc nơi món được tạotransferHistory[length - 1]-- lần chuyển gần nhất- Hoàn tác sẽ xóa mục cuối cùng; nếu mảng trống, sẽ đặt lại thành
null
3. Gộp đơn hàng
Gộp các món từ một hoặc nhiều đơn nguồn vào một đơn đích duy nhất.
3.1 Endpoint
POST /v1/api/sale/sale-orders/mergeBody yêu cầu:
{
sourceOrderIds: string[]; // Orders to merge FROM
targetOrderId: string; // Order to merge INTO
}3.2 Xác thực
| HTTP | Điều kiện |
|---|---|
| 400 | Đơn đích không ở trạng thái PROCESSING. Đơn nguồn phải là DRAFT hoặc PROCESSING. |
| 400 | Không tìm thấy đơn nguồn qua findOne({ where: { id, merchantId, saleChannelId } }) -- kết hợp kiểm tra tồn tại + merchant/chi nhánh |
| 400 | Bất kỳ đơn nào có checkSplitAt đã đặt -- hoàn tác hóa đơn trước |
3.3 Luồng xử lý chính
Mã nguồn: OrderMergeService.mergeOrders() trong packages/sale/src/services/order-merge.service.ts.
3.4 Quyết định thiết kế
| Quyết định | Lý do |
|---|---|
| Không gộp trùng | Các món được chuyển vẫn giữ là các dòng riêng biệt trên đơn đích, ngay cả khi sản phẩm đã tồn tại. Điều này đảm bảo tính toàn vẹn về giá từ đơn nguồn và cho phép hoàn tác. |
| Giữ giá gốc | Các món KHÔNG được tính lại giá sau khi gộp. Mỗi món giữ nguyên giá từ đơn nguồn. |
| Cùng merchant + kênh | Tất cả đơn hàng phải chung merchantId và saleChannelId. Được xác thực qua một truy vấn findOne() duy nhất. |
| Khóa theo thứ tự ID tăng dần | Tất cả đơn hàng được khóa theo thứ tự ID tăng dần để tránh deadlock khi các thao tác gộp đồng thời nhắm vào các tập đơn trùng lặp. |
| Bảo vệ checkSplitAt | Đơn hàng có hóa đơn đang hoạt động không thể gộp. Điều này tránh sai lệch tổng hóa đơn, phân mảnh lịch sử kiểm tra, và hóa đơn tự động không mong muốn. |
4. Hoàn tác gộp
Đảo ngược thao tác gộp -- khôi phục các đơn nguồn đã hủy và chuyển các món đã chuyển về lại.
4.1 Endpoint
DELETE /v1/api/sale/sale-orders/{id}/rollbackBody yêu cầu: Không có -- hoàn tác TẤT CẢ đơn nguồn đã gộp cùng lúc. Không hỗ trợ hoàn tác chọn lọc.
4.2 Xác thực
| HTTP | Điều kiện |
|---|---|
| 400 | Đơn hàng không ở trạng thái PROCESSING |
| 400 | Không tìm thấy món nào có transferHistory (không có gì để hoàn tác) |
| 400 | checkSplitAt đã đặt -- hoàn tác hóa đơn trước |
| 400 | Không tìm thấy đơn nguồn hoặc đơn nguồn không ở trạng thái CANCELLED với lý do MERGED_INTO_{targetId} |
4.3 Luồng xử lý chính
Mã nguồn: OrderMergeService.rollbackMerge() trong packages/sale/src/services/order-merge.service.ts.
4.4 Hành vi hoàn tác liên tiếp
Khi các thao tác gộp được thực hiện liên tiếp (C -> B -> A), hoàn tác được thực hiện từng bước -- mỗi lần hoàn tác chỉ đảo ngược bước gộp gần nhất:
| Bước | Hành động | Kết quả |
|---|---|---|
| 1 | Gộp C vào B | B có các món của C (lịch sử: [{C->B}]) |
| 2 | Gộp B vào A | A có tất cả các món (lịch sử: [{C->B}, {B->A}]) |
| 3 | Hoàn tác A | A mất các món của B. B được khôi phục thành PROCESSING với các món của C vẫn còn (lịch sử: [{C->B}]). |
| 4 | Hoàn tác B | B mất các món của C. C được khôi phục thành PROCESSING với các món gốc (lịch sử: null). |
5. Tách đơn hàng
Chia một đơn hàng thành nhiều đơn hàng độc lập. Mỗi đơn mới được tính lại giá qua PricingNetworkService để phản ánh chính xác chiết khấu theo số lượng và khuyến mãi.
5.1 Endpoint
POST /v1/api/sale/sale-orders/{id}/splitBody yêu cầu:
{
orders: [
{
name?: string; // Optional display name for new order
customerId?: string; // Optional customer assignment
items: [
{
saleOrderItemId: string; // Which item to move
quantity: number; // How much to move (can be partial)
}
]
}
// ... more order groups (at least 1 required)
]
}Phản hồi:
{
originalOrder: TSaleOrder; // Updated original (may have remaining items)
newOrders: TSaleOrder[]; // Newly created orders
}5.2 Xác thực
| HTTP | Điều kiện |
|---|---|
| 400 | Đơn hàng không ở trạng thái PROCESSING |
| 400 | checkSplitAt đã đặt (có hóa đơn đang hoạt động -- hoàn tác hóa đơn trước) |
| 400 | Không tìm thấy saleOrderItemId tham chiếu trên đơn hàng |
| 400 | Số lượng phải dương (> 0) |
| 400 | Tổng số lượng phân bổ cho một món vượt quá tổng số lượng của nó |
| 400 | Cần ít nhất một nhóm đơn hàng |
| 400 | Mỗi nhóm đơn hàng phải có ít nhất một món |
5.3 Luồng xử lý chính
Mã nguồn: OrderSplitService.split() trong packages/sale/src/services/order-split.service.ts.
5.4 Tách số lượng
Một món đơn lẻ có thể được phân bổ vào nhiều nhóm đơn hàng với số lượng từ phần.
Ví dụ: Một món có quantity = 5 được tách thành hai nhóm:
| Nhóm | Số lượng phân bổ | Cơ chế |
|---|---|---|
| Nhóm A | 2 | Tách từ phần -- số lượng món gốc giảm còn 3; tạo món mới với số lượng 2 trên đơn mới |
| Nhóm B | 3 | Chuyển toàn bộ (số lượng còn lại) -- món được chuyển hoàn toàn sang đơn mới |
Các món không được phân bổ vào bất kỳ nhóm nào sẽ nằm lại trên đơn gốc.
IMPORTANT
Sau khi tách, mọi đơn hàng bị ảnh hưởng (đơn gốc + các đơn mới) đều được tính lại giá qua PricingNetworkService.calculate(). Chiết khấu theo số lượng và khuyến mãi được tính lại dựa trên số lượng thực tế của mỗi đơn. Điều này có nghĩa là giá có thể thay đổi so với đơn gốc.
5.5 Hoàn tác = Gộp
Không có thao tác "hoàn tác tách" riêng. Thay vào đó, sử dụng mergeOrders() để gộp lại các đơn đã tách:
- Gọi
POST /sale-orders/mergevớitargetOrderId = đơn gốcvàsourceOrderIds = [đơnMớiA, đơnMớiB] - Các món được chuyển về với lịch sử chuyển được cập nhật
- Tất cả đơn hàng được tính lại giá (giá thay đổi lại -- điều này là bình thường)
Nếu đơn gốc đã bị hủy (tất cả món đã được chuyển đi), hãy gộp vào một trong các đơn mới.
5.6 Trường orderSplitAt
Khi tách đơn hàng xảy ra, orderSplitAt được đặt thành timestamp hiện tại trên cả đơn gốc (nếu còn món) và đơn gốc đã hủy (nếu tất cả món đã chuyển đi). Trường này chỉ mang tính thông tin -- không chặn bất kỳ thao tác nào tiếp theo.
checkSplitAt | orderSplitAt | Ý nghĩa |
|---|---|---|
| null | null | Đơn mới -- chưa có bất kỳ loại tách nào |
| đặt | null | Chỉ tách hóa đơn -- có hóa đơn đang hoạt động |
| null | đặt | Đã tách đơn hàng, còn món, không có hóa đơn |
| đặt | đặt | Đã tách đơn hàng + tách hóa đơn trên các món còn lại |
6. Các endpoint API
| Phương thức | Đường dẫn | Thao tác | Service |
|---|---|---|---|
| POST | /v1/api/sale/sale-orders/merge | Gộp đơn hàng | OrderMergeService.mergeOrders() |
| DELETE | /v1/api/sale/sale-orders/{id}/rollback | Hoàn tác gộp | OrderMergeService.rollbackMerge() |
| POST | /v1/api/sale/sale-orders/{id}/split | Tách đơn hàng | OrderSplitService.split() |
7. Sự kiện WebSocket
NOTE
Sự kiện WebSocket cho các thao tác gộp/tách đơn hàng đã được định nghĩa nhưng chưa được triển khai trong codebase hiện tại. Việc phát sự kiện đang bị comment trong tầng service. Các sự kiện dưới đây được lên kế hoạch cho phiên bản tương lai.
Tất cả sự kiện cấp đơn hàng được phát trên topic observation/sale/sale-order.
| Sự kiện | Điều kiện kích hoạt | Payload |
|---|---|---|
sale.order.merged | mergeOrders() hoàn tất | Đơn đích + ID các đơn nguồn đã hủy |
sale.order.mergeRolledBack | rollbackMerge() hoàn tất | Đơn đích + ID các đơn nguồn đã khôi phục |
sale.order.split | OrderSplitService.split() hoàn tất | Đơn gốc + các đơn mới |
8. Thao tác Combo Bundle
Khi 1 combo PV có trong order, lead SaleOrderItem cùng N children (link qua leadItemId) tạo thành unit atomic. Các flow edit và split bắt buộc tuân thủ:
8.1 Cập nhật item — cascade theo lead
SaleOrderItemService.update:
| Tình huống | Hành vi |
|---|---|
Patch 1 child (row có leadItemId !== null) | Reject COMBO_CHILD_EDIT_FORBIDDEN |
| Patch qty của lead (1 → N) | Scale qty mỗi child theo N/old; emit per-child applyReservationDelta cho delta tương ứng; persist qty mới của từng child riêng |
| Patch qty lead xuống ≤ 0 | Soft-delete tất cả child + lead; release toàn bộ reservation theo từng child |
Cascade reservation chạy trong cùng transaction với update của lead — bất kỳ leaf nào fail guard forceNonNegative SQL sẽ rollback toàn bộ thao tác.
8.2 Order split — combo atomicity
OrderSplitService._assertCombosAtomicAcrossGroups reject các request làm orphan 1 combo:
| Vi phạm | Lỗi |
|---|---|
| Group chứa lead nhưng thiếu children | COMBO_SPLIT_NOT_ATOMIC |
| Group chứa child nhưng thiếu lead | COMBO_SPLIT_NOT_ATOMIC |
| Group assign 1 phần qty của bất kỳ combo member | COMBO_SPLIT_NOT_ATOMIC |
Split combo hợp lệ phải đưa toàn bộ combo member (lead + children) vào cùng target group, mỗi item với full qty của row.
8.3 Order merge
Merge di chuyển cả đơn; nhóm combo tự động được bảo toàn vì leadItemId propagate cùng row. Không cần guard thêm.
ADR: developer/packages/inventory/decisions/0006-combo-explosion-at-cart-add.
9. Tài liệu liên quan
- Đơn hàng Bán -- Thực thể SaleOrder, vòng đời trạng thái, chế độ món
- Thanh toán -- Xử lý thanh toán và webhook
- WebSocket -- Hệ thống sự kiện thời gian thực và topic