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ắc | Mô 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ển | SaleCheckItem 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ổ sung | Hai 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/con | Cá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ột | Kiểu | Ghi chú |
|---|---|---|
id | snowflake | PK |
saleOrderId | FK -> SaleOrder | Bắt buộc |
status | text | PROCESSING (mặc định), PARTIAL, COMPLETED, CANCELLED |
subtotal | decimal(15,4) | Tính từ các món |
tax | decimal(15,4) | Tính từ các món |
discount | decimal(15,4) | Tính từ các món |
total | decimal(15,4) | Tính từ các món |
customerId | FK -> Customer | Người thanh toán (tuỳ chọn) |
createdBy | FK -> User | Audit |
modifiedBy | FK -> User | Audit |
3.3 Các cột SaleCheckItem
| Cột | Kiểu | Ghi chú |
|---|---|---|
id | snowflake | PK |
saleCheckId | FK -> SaleCheck | Bắt buộc |
saleOrderItemId | FK -> SaleOrderItem | Tham chiếu, không phải bản sao |
quantity | decimal(15,4) | Có thể là số lẻ (vd: 0.5) |
subtotal | decimal(15,4) | Tỷ lệ theo tỉ số số lượng |
tax | decimal(15,4) | Tỷ lệ theo tỉ số số lượng |
discount | decimal(15,4) | Tỷ lệ theo tỉ số số lượng |
total | decimal(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ạnh | Chi tiết |
|---|---|
| Kiểu | timestamp with time zone, nullable |
| Đặt khi | split() hoặc splitEqual() tạo check |
| Xoá khi | rollback() xoá tất cả check (đặt lại thành null) |
| Mục đích | Ngă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ác | checkSplitAt = null | checkSplitAt != 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àng | Cho phép | 400: 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:
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.quantityRà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ừ | Đến | Kích hoạt | Service |
|---|---|---|---|
| (tạo mới) | PROCESSING | split() hoặc splitEqual() | SaleCheckService (đơn hàng ở PROCESSING/PARTIAL, checkSplitAt null) |
| PROCESSING | PARTIAL | webhook ATTEMPT_SUCCESS, paid < total | SaleCheckPaymentWebhookService._handleCheckPaymentSuccess |
| PROCESSING / PARTIAL | COMPLETED | webhook ATTEMPT_SUCCESS, paid >= total | như trên; khi thanh toán đủ, có thể lan truyền đưa order về COMPLETED qua _checkOrderCompletionViaChecks |
| PROCESSING | CANCELLED | webhook 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
_checkOrderCompletionViaCheckstrongSaleCheckPaymentWebhookService. 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:
{
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ước | HTTP | Điều kiện |
|---|---|---|
| 1 | 404 | Không tìm thấy đơn hàng |
| 2 | 400 | Trạng thái đơn hàng không phải PROCESSING hoặc PARTIAL |
| 3 | 400 | checkSplitAt khác null (đã tách) |
| 4 | 400 | Một check trong request có 0 món |
| 5 | 400 | Số lượng món bất kỳ <= 0 |
| 6 | 400 | Một món trong đơn hàng không được gán cho check nào |
| 7 | 400 | Tổng số lượng gán != số lượng món trong đơn hàng |
Luồng chính:
- Khoá đơn hàng (
SELECT ... FOR UPDATE) - Tải các món trong đơn hàng
- Xác thực tất cả món được gán với số lượng đúng
- Tạo các bản ghi
SaleCheck(trạng thái: PROCESSING) - Tạo các bản ghi
SaleCheckItemvới tổng tỷ lệ - Đặt
checkSplitAt = now()trên đơn hàng - Tính lại tổng check qua SQL aggregation
Ghi nhật ký kiểm tra (action:SPLIT)- Commit transaction
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:
{
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ón | Số check | Phân phối | Giải thích |
|---|---|---|---|
| 7 | 3 | [3, 2, 2] | base=2, remainder=1 -> +1,+0,+0 |
| 7.5 | 3 | [3, 2.5, 2] | base=2, remainder=1.5 -> +1,+0.5,+0 |
| 2 | 3 | [1, 1, 0] | base=0, remainder=2 -> +1,+1,+0 |
| 10 | 4 | [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ón | Số check | Phân phối |
|---|---|---|
| 2 | 3 | [0.6667, 0.6667, 0.6666] |
| 7 | 3 | [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:
{
customerId?: string;
items?: [
{
saleOrderItemId: string;
quantity: string;
}
]
}Xác thực:
| HTTP | Điều kiện |
|---|---|
| 400 | Không tìm thấy check hoặc trạng thái không phải PROCESSING |
Luồng chính:
- Xác thực check tồn tại và là PROCESSING
- Cập nhật khách hàng nếu cung cấp
- 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
- Tính lại tổng check
- 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:
{
sourceCheckIds: string[]; // Các check gộp TỪ
targetCheckId: string; // Check gộp VÀO
}Xác thực:
| HTTP | Điều kiện |
|---|---|
| 400 | Bấ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:
- Khoá đơn hàng (
SELECT ... FOR UPDATE) - Xác thực tất cả ID check tồn tại và là PROCESSING
- Chuyển tất cả món từ check nguồn sang check đích
- Xoá mềm các check nguồn
- Tính lại tổng check đích
- 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 |
|---|---|
| 400 | Không có check nào trên đơn hàng |
| 400 | Bất kỳ check nào có trạng thái COMPLETED |
Luồng chính:
- Khoá đơn hàng (
SELECT ... FOR UPDATE) - Tải tất cả check của đơn hàng
- Xác thực không có check nào là COMPLETED
- Xoá mềm tất cả món check và check
- Xoá
checkSplitAtthànhnulltrên đơn hàng - 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ức | Endpoint | Mô tả | Mục |
|---|---|---|---|
| POST | /v1/api/sale/sale-orders/{id}/checks/split | Tách thủ công thành các check | 5.1 |
| POST | /v1/api/sale/sale-orders/{id}/checks/split-equal | Tự động phân phối món vào N check bằng nhau | 5.2 |
| PUT | /v1/api/sale/sale-checks/{checkId} | Cập nhật khách hàng hoặc món của check | 5.3 |
| POST | /v1/api/sale/sale-orders/{id}/checks/merge | Gộp các check nguồn vào check đích | 5.4 |
| DELETE | /v1/api/sale/sale-orders/{id}/checks | Hoàn tác tất cả check trên đơn hàng | 5.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ện | Kích hoạt |
|---|---|
sale.check.created | split() / splitEqual() |
sale.check.updated | updateCheck() |
sale.check.merged | merge() |
sale.check.rolledBack | rollback() |
sale.check.completed | Payment 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ệu | Mô tả |
|---|---|
| Đơn hàng Bán | Thực thể SaleOrder, trạng thái, và vòng đời chính |
| Thao tác Đơn hàng | Gộp và tách cấp đơn hàng (khác với tách check) |
| Thanh toán | Xử lý thanh toán và xử lý webhook |
| Sự kiện WebSocket | Hệ thống sự kiện thời gian thực và các topic |