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 bộ máy định giá.
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 gọn nhẹCheck sử dụng vòng đời 4 trạng thái: PROCESSING -> COMPLETED (qua thanh toán). Không có máy trạng thái 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 máy trạng thái. Đơn cha cần SPLIT/PARTIALLY_COMPLETED. Mọi handler phải 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, liền mạch.

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 -> UserAudit
modifiedByFK -> UserAudit

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 bộ máy định giá. Điều này bảo toàn giảm giá theo số lượng, giá gói, và phiếu giảm giá đã á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ạtService
(tạo mới)PROCESSINGsplit() hoặc splitEqual()SaleCheckService (đơn hàng ở PROCESSING/PARTIAL, checkSplitAt null)
PROCESSINGPARTIALwebhook ATTEMPT_SUCCESS, paid < totalSaleCheckPaymentWebhookService._handleCheckPaymentSuccess
PROCESSING / PARTIALCOMPLETEDwebhook ATTEMPT_SUCCESS, paid >= totalnhư trên; khi thanh toán đủ, có thể lan truyền đưa order về COMPLETED qua _checkOrderCompletionViaChecks
PROCESSINGCANCELLEDwebhook ATTEMPT_FAILED / EXPIRED / CANCELLED_handleCheckPaymentFailed / _Expired / _Cancelled
PROCESSING(xoá mềm)rollback()SaleCheckService - không check nào được ở COMPLETED
PROCESSING(xoá mềm)các check nguồn của merge()SaleCheckService - tất cả check ở PROCESSING

SaleCheck hỗ trợ thanh toán một phần (trạng thái PARTIAL). Order tự động hoàn tất khi TẤT CẢ check đạt COMPLETED - xem _checkOrderCompletionViaChecks trong SaleCheckPaymentWebhookService. Các check đã gộp hoặc hoàn tác được xoá mềm, không đặt thành 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 của 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 của 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 nhóm số lượng đã 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 của 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 của 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 (router) trích checkId và điều phối tới SaleCheckPaymentWebhookService.handleCheckEvent. Xem Payment Webhooks để biết đầy đủ hợp đồng của router.

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

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

Khi check PROCESSING/PARTIAL cuối cùng trên đơn hàng chuyển sang COMPLETED (qua thanh toán đủ), SaleOrder cha tự động chuyển sang COMPLETED (_checkOrderCompletionViaChecks). Điểm khách hàng được cộng nếu order.customerId được đặt. Lưu ý: luồng phía order còn đánh dấu AllocationUsage → SUCCESS; luồng lan truyền phía check hiện không làm việc đó một cách tường minh.


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ị chú thích lại (comment). Các sự kiện dưới đây là thiết kế dự kiến.

Tất cả sự kiện check sẽ đượ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 thời gian thực và các topic

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