Skip to content

Tách Check (Chia Hoá Đơn)

1. Tổng quan

Trong F&B phục vụ tại chỗ, một nhóm đặt món chung nhưng muốn thanh toán riêng. Hệ thống SaleCheck chia một SaleOrder thành các nhóm thanh toán độc lập gọi là check. Mỗi check có tổng riêng và có thể thanh toán riêng lẻ. Khi tất cả check được thanh toán, đơn hàng cha tự động hoàn tất.

Nguyên tắc thiết kế chính:

Nguyên tắcMô tả
Không tính lại giáCác món trong check mang phần chia tỷ lệ của giá đã tính. Giá không bao giờ được đưa lại vào pricing engine.
Món không di chuyểnSaleCheckItem là tham chiếu (FK) đến SaleOrderItem, không phải bản sao. Tồn kho chỉ trừ một lần ở cấp đơn hàng.
Schema bổ sungHai bảng mới (SaleCheck, SaleCheckItem). Không thay đổi schema bảng SaleOrder hiện tại (chỉ thêm trường bảo vệ).
Vòng đời đơn giảnCheck sử dụng vòng đời 4 trạng thái: PROCESSING -> COMPLETED (qua thanh toán). Không có state machine phức tạp.

Mã nguồn: SaleCheckService trong packages/sale/src/services/sale-check.service.ts, schema thực thể trong packages/core/src/models/schemas/sale/sale-check/.


2. Lý do thiết kế

Tại sao SaleCheck thay vì Đơn cha/con

Phương pháp thay thế -- tách đơn hàng thành các bản ghi SaleOrder cha/con -- đã được đánh giá và loại bỏ. SaleCheck là một nhóm thanh toán nhẹ trong đơn hàng, giúp tránh các vấn đề sau:

Vấn đề với Đơn cha/conCách SaleCheck giải quyết
Tính lại giá -- đơn con đưa lại vào PricingNetworkService. "Mua 3 tặng 1" bị hỏng khi tách thành qty=2 + qty=1. Bậc thuế theo số lượng thay đổi.Không bao giờ tính lại. checkItem.tax = orderItem.tax * (checkQty / orderQty)
Đếm đôi tồn kho -- đơn cha trừ 3, đơn con trừ 2+1 = tổng 6. Tồn kho cuối ngày không khớp.Món không di chuyển. SaleCheckItem là tham chiếu (FK), không phải bản sao. Tồn kho trừ một lần ở cấp đơn hàng.
Bùng nổ vòng đời -- N+1 state machine. Đơn cha cần SPLIT/PARTIALLY_COMPLETED. Mọi handler cần nhận biết cha/con.Đơn hàng giữ trạng thái PROCESSING. Vòng đời check (PROCESSING -> COMPLETED qua thanh toán). Đơn hàng tự động hoàn tất khi tất cả check COMPLETED.
Sai lệch báo cáo -- số lượng đơn tăng, doanh thu gấp đôi trừ khi mọi truy vấn thêm WHERE parentOrderId IS NULL.Không ảnh hưởng. Số lượng đơn, doanh thu, AOV không đổi. Check ẩn với phân tích tổng quan.
Hoàn tác phá huỷ -- bỏ tách = xoá đơn con + khôi phục đơn cha + tính lại giá + xử lý thanh toán một phần.Xoá mềm check + món. Món trong đơn hàng không bị ảnh hưởng.
Ô nhiễm schema -- SaleOrder thêm parentOrderId, isChild, splitType. Mọi truy vấn phải loại trừ đơn con.Không thay đổi bảng SaleOrder. Chỉ bổ sung (3 bảng mới).
Đồng thời -- tách đơn cần khoá phân tán trên đơn cha + tất cả đơn con.Một lệnh SELECT ... FOR UPDATE trên dòng đơn hàng cha. Một khoá, một dòng, nguyên tử.

Tiền lệ ngành

Toast, Square, Oracle MICROS, Lightspeed, và Clover đều sử dụng mô hình "check trong đơn hàng". Không có hệ thống F&B POS lớn nào sử dụng đơn cha/con để chia hoá đơn.

Đánh đổi chấp nhận

  • 2 bảng mới thay vì 0 bảng mới nhưng hàng trăm dòng logic vòng đời cha/con
  • Payment webhook có hai đường (đơn trực tiếp vs theo check) -- if/else rõ ràng, không phải gánh nặng bảo trì
  • Tổng check có thể chênh lệch làm tròn so với tổng đơn hàng (tối đa 0.0001 mỗi món, phần dư gán cho check đầu tiên)
  • Không thể tách đơn DRAFT -- theo thiết kế, giá phải được chốt trước

3. Mô hình dữ liệu

3.1 Quan hệ thực thể

Mã nguồn: packages/core/src/models/schemas/sale/sale-check/schema.ts, sale-check-item/schema.ts.

3.2 Các cột SaleCheck

CộtKiểuGhi chú
idsnowflakePK
saleOrderIdFK -> SaleOrderBắt buộc
statustextPROCESSING (mặc định), PARTIAL, COMPLETED, CANCELLED
subtotaldecimal(15,4)Tính từ các món
taxdecimal(15,4)Tính từ các món
discountdecimal(15,4)Tính từ các món
totaldecimal(15,4)Tính từ các món
customerIdFK -> CustomerNgười thanh toán (tuỳ chọn)
createdByFK -> UserKiểm tra
modifiedByFK -> UserKiểm tra

3.3 Các cột SaleCheckItem

CộtKiểuGhi chú
idsnowflakePK
saleCheckIdFK -> SaleCheckBắt buộc
saleOrderItemIdFK -> SaleOrderItemTham chiếu, không phải bản sao
quantitydecimal(15,4)Có thể là số lẻ (vd: 0.5)
subtotaldecimal(15,4)Tỷ lệ theo tỉ số số lượng
taxdecimal(15,4)Tỷ lệ theo tỉ số số lượng
discountdecimal(15,4)Tỷ lệ theo tỉ số số lượng
totaldecimal(15,4)Tỷ lệ theo tỉ số số lượng

3.4 Trường bảo vệ checkSplitAt

SaleOrder.checkSplitAt là trường timestamp nullable đóng vai trò bảo vệ đồng thời chính cho việc tách check.

Khía cạnhChi tiết
Kiểutimestamp with time zone, nullable
Đặt khisplit() hoặc splitEqual() tạo check
Xoá khirollback() xoá tất cả check (đặt lại thành null)
Mục đíchNgăn tách đôi -- chặn việc tách lần thứ hai khi check đang hoạt động

Ma trận bảo vệ:

Thao táccheckSplitAt = nullcheckSplitAt != null
Tách checkĐặt checkSplitAt = now()400: Đã tách
Tách đơn hàngĐặt orderSplitAt = now()400: Có check
Gộp đơn hàngCho phép400: Có check
Hoàn tác check--Đặt checkSplitAt = null

An toàn đồng thời: Kiểm tra bảo vệ chạy trong SELECT FOR UPDATE trên dòng đơn hàng. Hai thao tác tách đồng thời: thao tác đầu tiên giành khoá và đặt checkSplitAt; thao tác thứ hai đợi, đọc checkSplitAt khác null, và từ chối với lỗi 400.

3.5 Công thức tính tỷ lệ

Các trường tài chính của món trong check được tính là phần chia tỷ lệ của món trong đơn hàng cha:

typescript
checkItem.subtotal = orderItem.unitPrice * checkItem.quantity;
checkItem.tax      = orderItem.tax * (checkItem.quantity / orderItem.quantity);
checkItem.discount = orderItem.discount * (checkItem.quantity / orderItem.quantity);
checkItem.total    = orderItem.total * (checkItem.quantity / orderItem.quantity);

NOTE

Giá không bao giờ được tính lại qua pricing engine. Điều này bảo toàn giảm giá theo số lượng, giá gói, và coupon đã áp dụng ở cấp đơn hàng.

3.6 Ràng buộc chính: Toàn vẹn số lượng

Với mỗi SaleOrderItem, tổng SaleCheckItem.quantity trên tất cả các check phải bằng tổng số lượng của món trong đơn hàng:

SUM(SaleCheckItem.quantity WHERE saleOrderItemId = X) == SaleOrderItem.quantity

Ràng buộc này được thực thi ở cấp ứng dụng trong quá trình xác thực tách. Dung sai 0.0001 được áp dụng để xử lý làm tròn số thực trong các phép tách tỷ lệ.


4. Vòng đời SaleCheck

Chuyển trạng thái

Bảng trạng thái

TừĐếnKích hoạtBảo vệ
(tạo mới)PROCESSINGsplit() hoặc splitEqual()Đơn hàng là PROCESSING hoặc PARTIAL, checkSplitAt là null
PROCESSINGCOMPLETEDPayment webhook ATTEMPT_SUCCESS--
PROCESSING(xoá mềm)rollback()Không có check nào là COMPLETED
PROCESSING(xoá mềm)merge() (chỉ các check nguồn)Tất cả check liên quan là PROCESSING

IMPORTANT

Một check là nguyên tử từ góc độ thanh toán. Thanh toán toàn bộ số tiền check hoặc không -- không có thanh toán check một phần. Các check đã gộp hoặc hoàn tác được xoá mềm, không đặt thành trạng thái CANCELLED.


5. Các thao tác

5.1 Tách (Thủ công)

Gán thủ công các món trong đơn hàng vào các check.

Endpoint: POST /v1/api/sale/sale-orders/{id}/checks/split

Body request:

typescript
{
  checks: [
    {
      customerId?: string;      // Người thanh toán tuỳ chọn
      items: [
        {
          saleOrderItemId: string;
          quantity: string;        // String decimal (vd: "0.5")
        }
      ]
    }
  ]
}

Chuỗi xác thực:

BướcHTTPĐiều kiện
1404Không tìm thấy đơn hàng
2400Trạng thái đơn hàng không phải PROCESSING hoặc PARTIAL
3400checkSplitAt khác null (đã tách)
4400Một check trong request có 0 món
5400Số lượng món bất kỳ <= 0
6400Một món trong đơn hàng không được gán cho check nào
7400Tổng số lượng gán != số lượng món trong đơn hàng

Luồng chính:

  1. Khoá đơn hàng (SELECT ... FOR UPDATE)
  2. Tải các món trong đơn hàng
  3. Xác thực tất cả món được gán với số lượng đúng
  4. Tạo các bản ghi SaleCheck (trạng thái: PROCESSING)
  5. Tạo các bản ghi SaleCheckItem với tổng tỷ lệ
  6. Đặt checkSplitAt = now() trên đơn hàng
  7. Tính lại tổng check qua SQL aggregation
  8. Ghi nhật ký kiểm tra (action: SPLIT)
  9. Commit transaction
  10. Phát sự kiện WebSocket: sale.check.created

WARNING

Ghi nhật ký kiểm tra và thông báo WebSocket cho các thao tác check chưa được triển khai trong codebase hiện tại. Các phương thức service thực hiện các thao tác database nhưng không phát sự kiện.

Mã nguồn: SaleCheckService.split() trong packages/sale/src/services/sale-check.service.ts.

5.2 Tách đều (Tách tự động)

Tự động phân phối tất cả món vào N check bằng nhau.

Endpoint: POST /v1/api/sale/sale-orders/{id}/checks/split-equal

Body request:

typescript
{
  count: number;                          // 2-10
  mode: 'integer' | 'proportional';       // mặc định: 'proportional'
  names?: string[];                       // nhãn check tuỳ chọn
}

Hai chế độ phân phối:

Chế độ số nguyên (Integer Mode)

Phân phối đơn vị nguyên trước, phần lẻ còn lại cho check tiếp theo. Phù hợp cho các món không hợp lý khi là phân số (vd: "1 steak" không phải "0.6667 steak").

SL mónSố checkPhân phốiGiải thích
73[3, 2, 2]base=2, remainder=1 -> +1,+0,+0
7.53[3, 2.5, 2]base=2, remainder=1.5 -> +1,+0.5,+0
23[1, 1, 0]base=0, remainder=2 -> +1,+1,+0
104[3, 3, 2, 2]base=2, remainder=2 -> +1,+1,+0,+0

Chế độ tỷ lệ (Proportional Mode) (Mặc định)

Số lượng lẻ được làm tròn đến 4 chữ số thập phân. Check cuối hấp thụ phần dư làm tròn. Phù hợp để đảm bảo mỗi check có cùng tổng ("chia đều" UX).

SL mónSố checkPhân phối
23[0.6667, 0.6667, 0.6666]
73[2.3333, 2.3333, 2.3334]

Luồng nội bộ: splitEqual() xây dựng TSplitCheckRequest từ các bucket đã tính và uỷ thác cho split().

Mã nguồn: SaleCheckService.splitEqual() trong packages/sale/src/services/sale-check.service.ts.

5.3 Cập nhật Check

Chỉnh sửa check PROCESSING hiện có -- gán lại khách hàng, hoặc gán lại món.

Endpoint: PUT /v1/api/sale/sale-checks/{checkId}

Body request:

typescript
{
  customerId?: string;
  items?: [
    {
      saleOrderItemId: string;
      quantity: string;
    }
  ]
}

Xác thực:

HTTPĐiều kiện
400Không tìm thấy check hoặc trạng thái không phải PROCESSING

Luồng chính:

  1. Xác thực check tồn tại và là PROCESSING
  2. Cập nhật khách hàng nếu cung cấp
  3. Nếu cung cấp món: xoá mềm các món check hiện có, tạo món mới với tổng tỷ lệ đã tính lại
  4. Tính lại tổng check
  5. Commit transaction

5.4 Gộp Check

Kết hợp hai hoặc nhiều check trên cùng đơn hàng thành một check đích.

Endpoint: POST /v1/api/sale/sale-orders/{id}/checks/merge

Body request:

typescript
{
  sourceCheckIds: string[];   // Các check gộp TỪ
  targetCheckId: string;      // Check gộp VÀO
}

Xác thực:

HTTPĐiều kiện
400Bất kỳ check nào (nguồn hoặc đích) không tìm thấy hoặc trạng thái không phải PROCESSING

Luồng chính:

  1. Khoá đơn hàng (SELECT ... FOR UPDATE)
  2. Xác thực tất cả ID check tồn tại và là PROCESSING
  3. Chuyển tất cả món từ check nguồn sang check đích
  4. Xoá mềm các check nguồn
  5. Tính lại tổng check đích
  6. Commit transaction

5.5 Hoàn tác Check

Xoá tất cả check khỏi đơn hàng, đưa về trạng thái chưa tách.

Endpoint: DELETE /v1/api/sale/sale-orders/{id}/checks

Xác thực:

HTTPĐiều kiện
400Không có check nào trên đơn hàng
400Bất kỳ check nào có trạng thái COMPLETED

Luồng chính:

  1. Khoá đơn hàng (SELECT ... FOR UPDATE)
  2. Tải tất cả check của đơn hàng
  3. Xác thực không có check nào là COMPLETED
  4. Xoá mềm tất cả món check và check
  5. Xoá checkSplitAt thành null trên đơn hàng
  6. Commit transaction

NOTE

Hoàn tác là thao tác tất-cả-hoặc-không. Không thể hoàn tác từng check riêng lẻ -- sử dụng gộp để hợp nhất các check thay thế.


6. Thanh toán Check

Luồng Payment Webhook

Khi cổng thanh toán gửi webhook với sourceType: SaleCheck, PaymentWebhookService định tuyến đến xử lý riêng cho check.

Mã nguồn: PaymentWebhookService trong packages/sale/src/services/payment-webhook.service.ts.

Quy tắc tự động hoàn tất

Khi check PROCESSING cuối cùng trên đơn hàng chuyển sang COMPLETED, SaleOrder cha tự động chuyển sang COMPLETED. Không cần thao tác thủ công.


7. Ví dụ vòng đời đầy đủ

Luồng đầu-cuối: tách đơn hàng thành 2 check, thanh toán từng check riêng lẻ, đơn hàng tự động hoàn tất.

Kịch bản Tách, Gộp, Tách lại


8. Các endpoint API

Phương thứcEndpointMô tảMục
POST/v1/api/sale/sale-orders/{id}/checks/splitTách thủ công thành các check5.1
POST/v1/api/sale/sale-orders/{id}/checks/split-equalTự động phân phối món vào N check bằng nhau5.2
PUT/v1/api/sale/sale-checks/{checkId}Cập nhật khách hàng hoặc món của check5.3
POST/v1/api/sale/sale-orders/{id}/checks/mergeGộp các check nguồn vào check đích5.4
DELETE/v1/api/sale/sale-orders/{id}/checksHoàn tác tất cả check trên đơn hàng5.5

Mã nguồn: Định nghĩa route trong packages/sale/src/controllers/sale-check/definitions.ts.


9. Sự kiện WebSocket

WARNING

Thông báo WebSocket cho các thao tác check chưa được triển khai. Các lời gọi notifyCheckEvent() trong SaleCheckService hiện đang bị comment. Các sự kiện dưới đây là thiết kế dự kiến.

Tất cả sự kiện check được phát trên topic observation/sale/sale-check và broadcast đến các room của đơn hàng.

Sự kiệnKích hoạt
sale.check.createdsplit() / splitEqual()
sale.check.updatedupdateCheck()
sale.check.mergedmerge()
sale.check.rolledBackrollback()
sale.check.completedPayment webhook ATTEMPT_SUCCESS trên check

Mã nguồn: SaleSocketEventService.notifyCheckEvent() trong packages/sale/src/components/websocket/socket-event.service.ts.


10. Tài liệu liên quan

Tài liệuMô tả
Đơn hàng BánThực thể SaleOrder, trạng thái, và vòng đời chính
Thao tác Đơn hàngGộp và tách cấp đơn hàng (khác với tách check)
Thanh toánXử lý thanh toán và xử lý webhook
Sự kiện WebSocketHệ thống sự kiện real-time và các topic

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