Skip to content

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ácHướngMô tả
Gộp đơn hàngN -> 1Gộp các món từ nhiều đơn nguồn vào một đơn đích
Hoàn tác gộp1 -> 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àng1 -> NChia 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: OrderMergeServiceOrderSplitService 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 khiXóa khiChặn
checkSplitAtTạo tách hóa đơnHoàn tác hóa đơnGộp, hoàn tác gộp, tách đơn hàng
orderSplitAtThực hiện tách đơn hàngKhô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

typescript
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
nullMó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:

json
{
  "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:

json
{
  "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ạo
  • transferHistory[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/merge

Body yêu cầu:

typescript
{
  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.
400Khô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
400Bấ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 địnhLý do
Không gộp trùngCá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ốcCá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ênhTất cả đơn hàng phải chung merchantIdsaleChannelId. Được xác thực qua một truy vấn findOne() duy nhất.
Khóa theo thứ tự ID tăng dầnTấ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}/rollback

Body 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
400Không tìm thấy món nào có transferHistory (không có gì để hoàn tác)
400checkSplitAt đã đặt -- hoàn tác hóa đơn trước
400Khô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ướcHành độngKết quả
1Gộp C vào BB có các món của C (lịch sử: [{C->B}])
2Gộp B vào AA có tất cả các món (lịch sử: [{C->B}, {B->A}])
3Hoàn tác AA 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}]).
4Hoàn tác BB 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}/split

Body yêu cầu:

typescript
{
  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:

typescript
{
  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
400checkSplitAt đã đặt (có hóa đơn đang hoạt động -- hoàn tác hóa đơn trước)
400Không tìm thấy saleOrderItemId tham chiếu trên đơn hàng
400Số lượng phải dương (> 0)
400Tổng số lượng phân bổ cho một món vượt quá tổng số lượng của nó
400Cần ít nhất một nhóm đơn hàng
400Mỗ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ómSố lượng phân bổCơ chế
Nhóm A2Tá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 B3Chuyể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:

  1. Gọi POST /sale-orders/merge với targetOrderId = đơn gốcsourceOrderIds = [đơnMớiA, đơnMớiB]
  2. Các món được chuyển về với lịch sử chuyển được cập nhật
  3. 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.

checkSplitAtorderSplitAtÝ nghĩa
nullnullĐơn mới -- chưa có bất kỳ loại tách nào
đặtnullChỉ 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ẫnThao tácService
POST/v1/api/sale/sale-orders/mergeGộp đơn hàngOrderMergeService.mergeOrders()
DELETE/v1/api/sale/sale-orders/{id}/rollbackHoàn tác gộpOrderMergeService.rollbackMerge()
POST/v1/api/sale/sale-orders/{id}/splitTách đơn hàngOrderSplitService.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ạtPayload
sale.order.mergedmergeOrders() hoàn tấtĐơn đích + ID các đơn nguồn đã hủy
sale.order.mergeRolledBackrollbackMerge() hoàn tấtĐơn đích + ID các đơn nguồn đã khôi phục
sale.order.splitOrderSplitService.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ốngHà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 ≤ 0Soft-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ạmLỗi
Group chứa lead nhưng thiếu childrenCOMBO_SPLIT_NOT_ATOMIC
Group chứa child nhưng thiếu leadCOMBO_SPLIT_NOT_ATOMIC
Group assign 1 phần qty của bất kỳ combo memberCOMBO_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

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