Quản lý Phiếu Bếp
1. Tổng quan
Hệ thống Quản lý Phiếu Bếp kết nối giữa POS phía trước quầy (FOH — Front of House) và hệ thống hiển thị bếp phía sau (BOH — Back of House / KDS). Khi nhân viên phục vụ gửi món đến bếp, một KitchenTicket được tạo ra chứa một hoặc nhiều KitchenTicketItem. Nhân viên bếp chuyển trạng thái từng món qua các giai đoạn chế biến, và trạng thái phiếu tự động cập nhật dựa trên trạng thái các món.
Mã nguồn: Schema trong @nx/core (packages/core/src/models/schemas/sale/kitchen-ticket/, kitchen-ticket-item/).
Nguyên tắc thiết kế
| Nguyên tắc | Mô tả |
|---|---|
| Hủy và gửi lại | Sau khi gửi, không thể sửa trực tiếp món trong phiếu. Mọi thay đổi (số lượng, modifier, thay thế món) đều theo mô hình hủy + gửi lại — hủy phiếu/món cũ, rồi gửi phiếu mới với dữ liệu đúng |
| Tự động chuyển trạng thái | Trạng thái phiếu được tính tự động từ trạng thái các món qua evaluateTicketAutoProgression — không cần quản lý trạng thái phiếu thủ công |
| Gửi idempotent | idempotencyKey tùy chọn ngăn tạo phiếu trùng khi retry |
So sánh với ngành
Phương pháp hủy-và-gửi-lại là tiêu chuẩn ngành trên các nền tảng KDS lớn. Trong quá trình thiết kế, chúng tôi đã nghiên cứu và so sánh với 6 hệ thống KDS thương mại:
| Hệ thống | Thị trường | Điểm tương đồng |
|---|---|---|
| Toast POS | Dẫn đầu thị trường Mỹ (127K+ nhà hàng) | Cloud-based, tự động chuyển trạng thái từ trạm bếp đến phục vụ |
| Square KDS | SMB | Không thể sửa sau khi gửi — hủy và nhập lại |
| Lightspeed Restaurant | Mid-market, toàn cầu | Không thể sửa — hủy và nhập lại |
| Oracle MICROS Simphony | Chuỗi doanh nghiệp | KDS Controller điều phối tập trung |
| Aloha POS (NCR Voyix) | Nhà hàng full-service | Bắt buộc xác nhận hủy trên KDS, hủy và nhập lại |
| Odoo POS | Mã nguồn mở, tích hợp ERP | Chuyển trạng thái thủ công |
Kết quả chính:
- 5/6 hệ thống dùng hủy-và-gửi-lại cho thay đổi số lượng/modifier (chỉ Toast hỗ trợ sửa trực tiếp real-time)
- 5 trạng thái món (PENDING → COOKING → READY → SERVED + VOIDED) chi tiết hơn 3–4 trạng thái thông thường
evaluateTicketAutoProgressioncủa chúng tôi tinh vi hơn hầu hết các hệ thống về tự động chuyển trạng thái từ trạm bếp đến phục vụ- Điều phối bằng transaction tương đương kiến trúc KDS Controller của Oracle hay KDS Master của Aloha
2. Sơ đồ quan hệ thực thể
3. Vòng đời trạng thái
3.1. Trạng thái KitchenTicketItem
| Trạng thái | Giá trị nội bộ | Chuyển từ | Chuyển đến |
|---|---|---|---|
| PENDING | 103_PENDING | Tạo mới | COOKING, VOIDED |
| COOKING | 203_PROCESSING | PENDING | READY, VOIDED |
| READY | 302_SUCCESS | COOKING | SERVED, VOIDED |
| SERVED | 303_COMPLETED | READY | Kết thúc |
| VOIDED | 505_CANCELLED | PENDING, COOKING, READY | Kết thúc |
3.2. Trạng thái KitchenTicket (Tự động chuyển)
Trạng thái phiếu không bao giờ được đặt trực tiếp — được tính bởi evaluateTicketAutoProgression dựa trên tổng hợp trạng thái các món:
| Trạng thái | Giá trị nội bộ | Điều kiện kích hoạt |
|---|---|---|
| PENDING | 103_PENDING | Tạo mới, chưa có món nào bắt đầu |
| PROCESSING | 203_PROCESSING | Bất kỳ món nào đang COOKING, READY, hoặc SERVED |
| READY | 302_SUCCESS | Tất cả món active là READY, SERVED, hoặc VOIDED |
| COMPLETED | 303_COMPLETED | Tất cả món kết thúc, ít nhất 1 SERVED |
| VOIDED | 505_CANCELLED | TẤT CẢ món VOIDED, không có SERVED |
IMPORTANT
Phiếu có hỗn hợp SERVED + VOIDED sẽ thành COMPLETED, không phải VOIDED. Trạng thái VOIDED chỉ áp dụng khi tất cả các món đều bị hủy.
4. REST API
Đường dẫn gốc: /v1/api/sale
4.1. Endpoint FOH (POS Terminal)
Gửi đến bếp
Tạo phiếu bếp mới với các món từ đơn hàng.
POST /sale-orders/{saleOrderId}/kitchen-tickets/send| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
items | Array<{ saleOrderItemId, quantity }> | Có | Các món gửi (quantity dạng string) |
kitchenStationId | string | Không | Trạm bếp đích |
priority | number | Không | 0=bình thường (mặc định), 1=ưu tiên |
note | string | Không | Ghi chú hiển thị trên KDS (tối đa 500 ký tự) |
idempotencyKey | string | Không | Ngăn tạo phiếu trùng khi retry (tối đa 100 ký tự) |
Response: KitchenTicket với mảng items.
Idempotency: Nếu idempotencyKey trùng với phiếu hiện có của cùng đơn hàng, phiếu hiện tại được trả về mà không tạo mới.
Hủy phiếu
Hủy tất cả món chưa kết thúc trong phiếu. Món SERVED không bị ảnh hưởng.
POST /kitchen-tickets/{ticketId}/void| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
reason | string | Không | Lý do hủy (tối đa 500 ký tự) |
Response: KitchenTicket — trạng thái là VOIDED nếu tất cả món bị hủy, COMPLETED nếu một số đã SERVED.
Ưu tiên phiếu
Đánh dấu phiếu là ưu tiên cao.
POST /kitchen-tickets/{ticketId}/rush| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
reason | string | Không | Lý do ưu tiên (tối đa 500 ký tự) |
Response: KitchenTicket với priority: 1 và metadata.rushReason.
Hủy một món
POST /kitchen-ticket-items/{itemId}/void| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
reason | string | Không | Lý do hủy |
Response: KitchenTicketItem với trạng thái VOIDED. Tự động cập nhật trạng thái phiếu cha.
4.2. Endpoint BOH (KDS)
Chuyển trạng thái từng món
POST /kitchen-ticket-items/{itemId}/start-cooking # PENDING → COOKING
POST /kitchen-ticket-items/{itemId}/ready # COOKING → READY
POST /kitchen-ticket-items/{itemId}/served # READY → SERVEDKhông cần body. Mỗi endpoint trả về KitchenTicketItem đã cập nhật và tự động chuyển trạng thái phiếu cha.
Thao tác hàng loạt trên phiếu
POST /kitchen-tickets/{ticketId}/ready # Tất cả PENDING/COOKING → READY
POST /kitchen-tickets/{ticketId}/served # Tất cả READY → SERVEDKhông cần body. Trả về KitchenTicket đã cập nhật với danh sách món.
4.3. Endpoint đọc dữ liệu
| Method | Đường dẫn | Mô tả |
|---|---|---|
GET | /kitchen-tickets | Danh sách phiếu (filter, include, phân trang) |
GET | /kitchen-tickets/{id} | Lấy phiếu theo ID |
GET | /kitchen-ticket-items | Danh sách món (filter, phân trang) |
GET | /kitchen-ticket-items/{id} | Lấy món theo ID |
Bộ lọc thường dùng:
GET /kitchen-tickets?filter[saleOrderId]=xxx&include=items
GET /kitchen-tickets?filter[kitchenStationId]=xxx&filter[status][$ne]=505_CANCELLED
GET /kitchen-ticket-items?filter[kitchenTicketId]=xxx4.4. Tóm tắt endpoint
FOH chỉ có 3 thao tác ghi cho phiếu:
| Thao tác | Chức năng |
|---|---|
| Gửi | Tạo phiếu mới với các món |
| Hủy phiếu | Hủy tất cả món active trong phiếu |
| Hủy món | Hủy một món đơn lẻ |
Mọi thao tác khác (thay đổi số lượng, modifier, thay thế món, gửi lại) đều là tổ hợp hủy + gửi. Đây là cách tiếp cận tương tự Square, Lightspeed, Aloha, và Oracle MICROS.
5. Sự kiện WebSocket
5.1. Topic
| Topic | Giá trị |
|---|---|
| Phiếu bếp | wt:observation/sale/kitchen-ticket |
| Món trong phiếu | wt:observation/sale/kitchen-ticket-item |
5.2. Room
Sự kiện được phát đến nhiều room đồng thời:
| Room | Pattern | Người đăng ký |
|---|---|---|
| Merchant (tất cả) | wr:observation/merchants/{merchantId} | Dashboard |
| Merchant Kitchen | wr:observation/merchants/{merchantId}/kitchen | Tất cả màn hình KDS |
| Order Kitchen | wr:observation/sale-orders/{saleOrderId}/kitchen | Chi tiết đơn POS |
| Station | wr:observation/kitchen-stations/{stationId} | KDS theo trạm |
| Ticket | wr:observation/kitchen-tickets/{ticketId} | Chi tiết phiếu |
| Ticket Items | wr:observation/kitchen-tickets/{ticketId}/items | Sự kiện cấp món |
| Specific Item | wr:observation/kitchen-ticket-items/{itemId} | Một món cụ thể |
5.3. Action
| Action | Kích hoạt bởi | Payload |
|---|---|---|
TICKET_CREATED | sendToKitchen | { ticket, items[], merchantId } |
TICKET_VOIDED | voidTicket | { ticket, items[], merchantId } |
TICKET_RUSHED | rushTicket | { ticket, items[], merchantId } |
TICKET_STATUS_CHANGED | markTicketReady, markTicketServed | { ticket, items[], merchantId } |
ITEM_VOIDED | voidTicketItem | { ticket, item, merchantId } |
ITEM_STATUS_CHANGED | startCookingItem, markItemReady, markItemServed | { ticket, item, merchantId } |
6. Service
6.1. KitchenTicketService
Mã nguồn: src/services/kitchen-ticket.service.tsDI Dependencies: SaleOrderRepository, SaleOrderItemRepository, KitchenStationRepository, KitchenTicketRepository, KitchenTicketItemRepository, SaleSocketEventService
| Phương thức | Mô tả |
|---|---|
sendToKitchen | Xác thực đơn + món + trạm, tạo phiếu + món trong transaction. Hỗ trợ idempotency key để ngăn phiếu trùng. |
voidTicket | Hủy tất cả món active (PENDING/COOKING/READY) trong một câu SQL. Món SERVED không bị ảnh hưởng. Tự động cập nhật trạng thái phiếu. Lưu lý do hủy vào metadata. |
rushTicket | Đặt priority=1 và lưu lý do ưu tiên. Không cần transaction (update đơn giản). |
markTicketReady | Cập nhật hàng loạt tất cả món PENDING/COOKING → READY. Tự động cập nhật trạng thái phiếu. |
markTicketServed | Cập nhật hàng loạt tất cả món READY → SERVED. Tự động cập nhật trạng thái phiếu. |
6.2. KitchenTicketItemService
Mã nguồn: src/services/kitchen-ticket-item.service.tsDI Dependencies: KitchenTicketRepository, KitchenTicketItemRepository, SaleSocketEventService
| Phương thức | Mô tả |
|---|---|
voidTicketItem | Hủy một món (kiểm tra canVoid). Tự động cập nhật trạng thái phiếu cha. |
startCookingItem | PENDING → COOKING. Đặt startedAt. Tự động chuyển phiếu sang PROCESSING. |
markItemReady | COOKING → READY. Đặt readyAt. Có thể tự động chuyển phiếu sang READY. |
markItemServed | READY → SERVED. Đặt servedAt. Có thể tự động chuyển phiếu sang COMPLETED. |
Tất cả chuyển trạng thái món dùng chung helper _transitionItemStatus để xác thực guard, cập nhật món, và gọi evaluateTicketAutoProgression.
7. Repository
| Repository | Thực thể | Phương thức đặc biệt |
|---|---|---|
KitchenTicketRepository | KitchenTicket | getNextSequence, findByIdempotencyKey, evaluateTicketAutoProgression |
KitchenTicketItemRepository | KitchenTicketItem | findByTicketId, findBySaleOrderItemId, findActiveByTicketId |
KitchenStationRepository | KitchenStation | CRUD chuẩn |
evaluateTicketAutoProgression
Mã nguồn: packages/core/src/repositories/sale/kitchen-ticket.repository.ts
Chạy 4 câu SQL cập nhật có điều kiện theo thứ tự ưu tiên (tất cả trong cùng transaction):
- Tất cả món VOIDED → phiếu
VOIDED - Tất cả SERVED/VOIDED (1+ SERVED) → phiếu
COMPLETED - Tất cả READY/SERVED/VOIDED → phiếu
READY - Bất kỳ COOKING/READY/SERVED → phiếu
PROCESSING - Còn lại → không thay đổi (vẫn PENDING)
Mỗi bước dùng subquery EXISTS/NOT EXISTS để tránh race condition.
8. Quyền hạn
| Mã | Action | Mô tả |
|---|---|---|
KitchenTicket.send | CREATE | Gửi món đến bếp |
KitchenTicket.void | DELETE | Hủy phiếu bếp |
KitchenTicket.rush | UPDATE | Đánh dấu phiếu ưu tiên |
KitchenTicket.updateTicketStatus | UPDATE | Cập nhật trạng thái hàng loạt (KDS) |
KitchenTicketItem.voidItem | DELETE | Hủy một món |
KitchenTicketItem.updateItemStatus | UPDATE | Chuyển trạng thái món (KDS) |
9. Kịch bản vận hành
9.1. Luồng bình thường
Phục vụ: Tạo SaleOrder → Thêm món
Phục vụ: POST /sale-orders/{id}/kitchen-tickets/send
→ Phiếu PENDING, tất cả món PENDING
Đầu bếp: POST /kitchen-ticket-items/{id}/start-cooking (từng món)
→ Món COOKING, phiếu tự động → PROCESSING
Đầu bếp: POST /kitchen-ticket-items/{id}/ready (từng món)
→ Món READY, khi tất cả ready → phiếu tự động → READY
Phục vụ: POST /kitchen-ticket-items/{id}/served (từng món)
→ Món SERVED, khi tất cả served → phiếu tự động → COMPLETED9.2. Gửi theo đợt
Phục vụ: Gửi khai vị → Phiếu #1 (sequence: 1)
...khách ăn xong...
Phục vụ: Gửi món chính → Phiếu #2 (sequence: 2)Mỗi lần gọi sendToKitchen tạo phiếu mới. Trường sequence tự tăng theo SaleOrder.
9.3. Thay đổi số lượng sau khi gửi (Hủy + Gửi lại)
Mọi thay đổi số lượng đều theo mô hình hủy + gửi lại:
Khách: "Đổi 3 Burger thành 1 Burger"
Cách A — Hủy toàn bộ phiếu và gửi lại:
Phục vụ: POST /kitchen-tickets/{ticket1Id}/void
{ "reason": "Đổi số lượng — khách muốn 1 burger" }
Phục vụ: POST /sale-orders/{id}/kitchen-tickets/send
{ "items": [{ "saleOrderItemId": "burger-id", "quantity": "1" }] }
Cách B — Hủy từng món (nếu phiếu có món khác cần giữ):
Phục vụ: POST /kitchen-ticket-items/{burgerItemId}/void
{ "reason": "Đổi số lượng" }
Phục vụ: POST /sale-orders/{id}/kitchen-tickets/send
{ "items": [{ "saleOrderItemId": "burger-id", "quantity": "1" }] }TIP
FE nên kiểm tra trạng thái món trước khi hủy. Nếu món đang COOKING hoặc READY, cảnh báo người dùng ("Món đang được chế biến. Vẫn hủy?"). Món SERVED không thể hủy.
9.4. Thay thế món
Phục vụ: POST /kitchen-ticket-items/{burgerId}/void { reason: "Đổi sang gà" }
Phục vụ: POST /sale-orders/{id}/kitchen-tickets/send { items: [chicken] }9.5. Gửi lại (Làm lại)
Phục vụ: POST /kitchen-ticket-items/{originalId}/void { reason: "Rơi đĩa" }
Phục vụ: POST /sale-orders/{id}/kitchen-tickets/send
{ items: [cùng món], priority: 1, note: "LÀM LẠI — rơi đĩa" }Đặt priority: 1 để ưu tiên. Trường note hiển thị trên KDS.
9.6. Hủy toàn bộ phiếu
Phục vụ: POST /kitchen-tickets/{ticketId}/void { reason: "Bàn hủy đơn" }
Hệ thống: Tất cả PENDING/COOKING/READY → VOIDED
Món SERVED không bị ảnh hưởng
Nếu tất cả VOIDED → phiếu VOIDED
Nếu hỗn hợp SERVED+VOIDED → phiếu COMPLETED9.7. Cây quyết định
Phục vụ muốn thay đổi sau khi gửi?
│
├── Đổi số lượng (tăng hoặc giảm)?
│ └── Hủy phiếu → gửi lại với số lượng mới
│ (hoặc hủy từng món nếu phiếu có món khác cần giữ)
│
├── Xóa món hoàn toàn?
│ └── Hủy món (hoặc hủy phiếu nếu chỉ còn món đó)
│
├── Thay thế món?
│ └── Hủy món cũ → gửi món mới
│
├── Đổi modifier?
│ └── Hủy món cũ → gửi món mới với modifier đã cập nhật
│
├── Làm lại (re-fire)?
│ └── Hủy món cũ → gửi món mới với priority: 1
│
├── Ưu tiên?
│ └── POST /kitchen-tickets/{id}/rush
│
└── Hủy tất cả?
└── POST /kitchen-tickets/{id}/void10. Tích hợp Frontend
10.1. POS Terminal (FOH)
| Trách nhiệm | Cài đặt |
|---|---|
| Gửi món chưa gửi đến bếp | POST /sale-orders/{id}/kitchen-tickets/send |
| Hủy món/phiếu khi khách hủy | POST /kitchen-ticket-items/{id}/void hoặc /kitchen-tickets/{id}/void |
| Thay đổi số lượng/modifier/thay thế | Hủy phiếu/món cũ → gửi lại với dữ liệu đúng |
| Ưu tiên phiếu | POST /kitchen-tickets/{id}/rush |
| Theo dõi trạng thái bếp theo đơn | Subscribe wr:observation/sale-orders/{saleOrderId}/kitchen |
| Idempotency khi retry | Bao gồm idempotencyKey trong request gửi |
| Xác thực trước khi hủy | Kiểm tra trạng thái món — cảnh báo nếu COOKING/READY, chặn nếu SERVED |
10.2. Màn hình KDS (BOH)
| Trách nhiệm | Cài đặt |
|---|---|
| Danh sách phiếu active theo trạm | GET /kitchen-tickets?filter[kitchenStationId]={id}&filter[status][$ne]=505_CANCELLED |
| Chuyển trạng thái từng món | POST /kitchen-ticket-items/{id}/start-cooking, /ready, /served |
| Hoàn thành phiếu hàng loạt | POST /kitchen-tickets/{id}/ready hoặc /served |
| Hiển thị chỉ báo ưu tiên | Kiểm tra ticket.priority === 1 |
| Hiển thị chỉ báo hủy | Flash/highlight món bị hủy để đầu bếp xác nhận |
| Sắp xếp theo ưu tiên + thời gian | Sắp xếp theo priority DESC, pendingAt ASC |
| Cập nhật real-time | Subscribe wr:observation/kitchen-stations/{stationId} |
10.3. Xử lý đồng thời
| Cơ chế | Mô tả |
|---|---|
| Idempotency key | Bao gồm idempotencyKey trong request gửi (vd: {saleOrderId}-{timestamp}-{random}) — ngăn phiếu trùng khi retry |
| Xác thực SaleOrderItem | sendToKitchen xác thực từng saleOrderItemId tồn tại — nếu phục vụ khác đã xóa món, request trả lỗi 404 |
| Guard trạng thái khi hủy | Hủy món đã VOIDED hoặc SERVED trả lỗi 400 — FE nên xử lý gracefully và refresh state từ WS |
| State từ WS | Khi nhận sự kiện WS, refresh trạng thái phiếu/món thay vì dựa vào state cục bộ |
11. Tham chiếu mô hình dữ liệu
KitchenTicket
| Trường | Kiểu | Mô tả |
|---|---|---|
id | string | Snowflake ID |
ticketNumber | string | Định danh duy nhất (timestamp-snowflake) |
saleOrderId | string | Đơn hàng cha |
merchantId | string | Merchant sở hữu |
kitchenStationId | string? | Trạm bếp được gán (nullable) |
status | string | Trạng thái hiện tại (tự động chuyển) |
priority | number | 0=bình thường, 1=ưu tiên |
sequence | number | Thứ tự trong SaleOrder |
pendingAt | Date | Thời điểm tạo |
processingAt | Date? | Thời điểm món đầu tiên bắt đầu nấu |
readyAt | Date? | Thời điểm tất cả món sẵn sàng |
completedAt | Date? | Thời điểm tất cả món được phục vụ |
voidedAt | Date? | Thời điểm hủy |
metadata | object | { note?, idempotencyKey?, rushReason?, voidReason? } |
KitchenTicketItem
| Trường | Kiểu | Mô tả |
|---|---|---|
id | string | Snowflake ID |
kitchenTicketId | string | Phiếu cha |
saleOrderItemId | string | Món trong đơn hàng liên kết |
quantity | string | Số lượng (decimal dạng string) |
status | string | Trạng thái hiện tại |
startedAt | Date? | Thời điểm bắt đầu nấu |
readyAt | Date? | Thời điểm đánh dấu sẵn sàng |
servedAt | Date? | Thời điểm phục vụ cho khách |
voidedAt | Date? | Thời điểm hủy |
metadata | object | { productMetadata?, modifiers?, specialInstructions?, voidReason? } |
12. Tài liệu liên quan
| Tài liệu | Mô tả |
|---|---|
| Đơn hàng Bán | Thực thể SaleOrder, chế độ món, vòng đời trạng thái |
| Sự kiện Real-Time | Hạ tầng WebSocket, tích hợp Signal |
| Luồng Checkout | Luồng checkout và thanh toán |