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 order có một combo PV, dòng lead SaleOrderItem cùng N dòng con (liên kết qua leadItemId) tạo thành một khối không thể tách rời. Các luồng chỉnh sửa và tách phải tuân thủ điều này:

8.1 Cập nhật item - lan truyền theo lead

SaleOrderItemService.update:

Tình huốngHành vi
Sửa một dòng con (dòng có leadItemId !== null)Từ chối COMBO_CHILD_EDIT_FORBIDDEN
Sửa số lượng của lead (1 → N)Nhân số lượng mỗi dòng con theo N/cũ; phát applyReservationDelta cho từng dòng con theo phần chênh lệch; ghi lại số lượng mới của từng dòng con riêng lẻ
Sửa số lượng lead xuống ≤ 0Xoá mềm toàn bộ dòng con + dòng lead; giải phóng mọi reservation của từng dòng con

Việc lan truyền reservation chạy trong cùng transaction với cập nhật của lead - nếu bất kỳ dòng con nào vi phạm guard SQL forceNonNegative thì toàn bộ thao tác sẽ rollback.

8.2 Tách order - tính không thể tách rời của combo

OrderSplitService._assertCombosAtomicAcrossGroups từ chối các request làm một combo bị mồ côi:

Vi phạmLỗi
Nhóm chứa lead nhưng thiếu các dòng conCOMBO_SPLIT_NOT_ATOMIC
Nhóm chứa dòng con nhưng thiếu leadCOMBO_SPLIT_NOT_ATOMIC
Nhóm gán một phần số lượng cho bất kỳ thành phần combo nàoCOMBO_SPLIT_NOT_ATOMIC

Một lần tách combo hợp lệ phải đưa toàn bộ thành phần combo (lead + các dòng con) vào cùng một nhóm đích, mỗi item với đầy đủ số lượng của dòng.

8.3 Gộp order

Gộp di chuyển trọn vẹn cả đơn; nhóm combo được bảo toàn tự động vì leadItemId đi cùng dòng. 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.