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 pricing engine. |
| 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 đơn giản | Check 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/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 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ộ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 | Kiểm tra |
modifiedBy | FK -> User | Kiểm tra |
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 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.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 | Bảo vệ |
|---|---|---|---|
| (tạo mới) | PROCESSING | split() hoặc splitEqual() | Đơn hàng là PROCESSING hoặc PARTIAL, checkSplitAt là null |
| PROCESSING | COMPLETED | Payment 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:
{
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 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 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:
{
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 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 đị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ứ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ị 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ệ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 real-time và các topic |