Ca làm việc (Shift v2)
1. Tổng quan
| Thuộc tính | Giá trị |
|---|---|
| ID | FEAT-SALE-SHIFT |
| Trạng thái | Stable (v2 - thay thế hoàn toàn PosSession v1) |
| Owner | sale-team |
| Phụ thuộc | Device, SaleChannel, SaleOrder (shiftId/openedInShiftEnrollmentId/closedInShiftEnrollmentId), FinanceVoucher (shiftId), FinanceTransaction (shiftEnrollmentId) |
| PRD | PRD-SHF-001 - Ca Đa nhân viên |
Một Shift là ca làm việc trên một kênh bán (merchant + saleChannel). Một kênh có thể có nhiều ca mở cùng lúc - thiết bị hoặc tạo ca mới, hoặc tham gia một ca đang mở (mỗi thiết bị chỉ ở 1 ca mở tại một thời điểm). Thiết bị có cashDrawerEnabled sinh một ShiftDrawer với quỹ đầu ca. Nhân viên thao tác trên thiết bị được tự động ghi danh vào ca (xem §4). Đóng ca khoá bán hàng toàn cửa hàng; từng két đối soát riêng; két cuối cùng đối soát xong thì ca chốt (RECONCILED) kèm báo cáo Z tổng hợp.
API cũ
/pos/sessions/*(PosSession v1) đã bị gỡ hoàn toàn. Mọi tích hợp dùng/shifts/*.
2. Mô hình thực thể
ShiftEnrollmentlà đa hình:principalType = 'Device' | 'User',principalId= deviceId hoặc userId. Mỗi principal chỉ có một ghi danh201_ACTIVATEDtại một thời điểm (partial unique index).- Tiền:
FinanceVoucher.shiftId(cấp phiếu - theo ca) vàFinanceTransaction.shiftEnrollmentId(cấp bút toán - theo két/thiết bị).
3. Vòng đời
| Đối tượng | Giá trị | Ý nghĩa |
|---|---|---|
| Shift | 100_OPENING | Đang mở - bán hàng bình thường |
| Shift | 200_CLOSED | Đã đóng - KHÓA bán hàng, chờ đối soát két |
| Shift | 300_RECONCILED | Đã đối soát đủ - có Z tổng hợp |
| Drawer | 100_OPENING / 200_CLOSED / 300_RECONCILED | Tương tự, theo từng két |
Bất biến & hành vi chính:
- Một kênh có thể có nhiều ca
OPENINGcùng lúc; mỗi thiết bị/nhân viên chỉ giữ tối đa một ghi danh ACTIVATED (đó mới là bất biến "một ca mở"). - Đóng ca KHÔNG gỡ ghi danh thiết bị - drop-out là lựa chọn của thu ngân lúc reconcile (
dropOut: true). Thiết bị không drop-out được carry-over sang ca kế (vẫn nhập quỹ đầu ca mới). - Gate mở ca mới: mở ca mới KHÔNG bao giờ bị ca trước chặn. Đơn chưa hoàn tất được chuyển sang ca mới (
DRAFT/PROCESSING/PARTIALre-parent sang ca mới; đơn đã chốt giữ nguyên), và ca trước không cần đối soát trước. Toàn vẹn tiền được kiểm ở mức từng két khi mở: mở két có phiên cũ200_CLOSED-chưa-đối-soát bị từ chối bằngdrawer.pending_reconcilecho tới khi két đó được đối soát (hiển thị quapendingReconcileở/drawer-devices). - Ca không có két nào → chốt thẳng
RECONCILEDngay khi đóng.
4. Theo dõi nhân viên trong ca (tự động)
Ngoài thiết bị, hệ thống ghi danh nhân viên (principalType = 'User') vào ca - hoàn toàn tự động, FE không cần gọi API nào:
| Thời điểm | Hành vi |
|---|---|
| Nhân viên mở/join ca | Ghi danh USER + event CHECK_IN (cùng transaction với ghi danh thiết bị) |
| Nhân viên khác đăng nhập thiết bị và bán hàng (tạo đơn / checkout) | Lazy check-in: tự ghi danh vào ca của thiết bị ở lần thao tác đầu tiên |
| Nhân viên chuyển sang thao tác ở ca khác | Tự CHECK_OUT khỏi ca cũ, CHECK_IN ca mới - hiện diện đi theo nơi thao tác gần nhất |
Ca chốt (RECONCILED) | Mọi nhân viên còn ACTIVATED được check-out (leftAt = giờ chốt) |
- Danh tính nhân viên lấy từ JWT của request - vì vậy yêu cầu vận hành duy nhất cho FE: mỗi nhân viên đăng nhập bằng tài khoản của chính mình trên thiết bị (không dùng chung tài khoản), nếu muốn dữ liệu hiện diện/bàn giao đúng người.
- Theo dõi hiện diện là best-effort: lỗi ghi danh nhân viên không bao giờ chặn giao dịch bán.
- Khoảng hiện diện =
enrolledAt→leftAttrên bản ghi danh USER; dòng thời gian chi tiết nằm ở event ledger.
Event ledger (ShiftEnrollmentEvent.type):
| Event | Principal | Ý nghĩa |
|---|---|---|
DROP_IN / DROP_OUT | Device | Thiết bị vào / rời ca (drop-out chọn lúc reconcile) |
CHECK_IN / CHECK_OUT | User | Nhân viên vào / rời ca |
DRAWER_OPEN | Device | Mở két (amount = quỹ đầu ca) |
PAY_IN / PAY_OUT / SAFE_DROP | Device | Thu / chi / rút cất két (amount bắt buộc; sinh phiếu thu/chi kế toán) |
RECONCILE | Device | Đối soát két (amount = tiền đếm thực tế, note = ghi chú thu ngân) |
5. Xác thực & Header
| Header | Bắt buộc | Ghi chú |
|---|---|---|
Authorization: Bearer <JWT> | Tất cả endpoint | Danh tính nhân viên (xem §4) |
x-device-info: <deviceId> | Tất cả endpoint trừ GET /shifts (danh sách) | Backend tra thiết bị để suy ra merchant. KHÔNG gửi deviceId qua body/query. |
x-merchant-id: <merchantId> | Chỉ GET /shifts (back-office) | Scope merchant cho màn không có thiết bị. |
Điều kiện tiên quyết: merchant bật enableShiftManagement; thiết bị đăng ký đủ (merchantId, status = 201_ACTIVATED, cashDrawerEnabled đúng); role có quyền subject Shift.
Tắt tính năng giữa ca
Toggle enableShiftManagement chỉ chặn MỞ ca mới (POST /shifts / POST /shifts/{id}/join → management.disabled). Ca đang mở vẫn vận hành trọn vòng: bán hàng tiếp tục (đơn không còn gắn ca), thu/chi két, đóng ca, đối soát và mọi báo cáo X/Z đều hoạt động bình thường - tắt tính năng không bao giờ làm kẹt ca đang mở.
6. REST Endpoints
| Verb | Path | Mục đích |
|---|---|---|
GET | /shifts/current | Trạng thái thiết bị - nguồn điều hướng màn hình duy nhất |
POST | /shifts | Tạo ca - { saleChannelId, name?, notes?, drawerDeviceId?, openingFloat?, startAt?, endAt? }. name/notes là i18n { default, en?, vi? } |
POST | /shifts/{id}/join | Tham gia ca đang mở - { drawerDeviceId?, openingFloat?, notes? } (notes i18n) |
POST | /shifts/cash-movement | Thu/chi két - { drawerId, type: PAY_IN|PAY_OUT|SAFE_DROP, amount, reason? } |
POST | /shifts/close | Đóng ca - { shiftId }; khoá bán toàn bộ |
POST | /shifts/drawers/{id}/reconcile | Đối soát két - { actualCash, actualNonCash?, acknowledgeDiscrepancy?, dropOut?, notes? }. actualNonCash chỉ nhận key BANK/QR_CODE/MOBILE_POS (key lạ bị từ chối) |
GET | /shifts/{id}/report | Bundle { shift, devices[] } - toàn ca + từng thiết bị (X live khi đang mở, Z khi đã chốt; bundle.shift = tổng hợp). ?deviceId= thu hẹp 1 thiết bị |
POST | /shifts/{id}/report/snapshot | Lưu một mốc point-in-time của bundle (idempotent - 409 snapshot.unchanged); list qua /report/snapshots, chi tiết /report/snapshots/{snapshotId} |
GET | /shifts/z-report | Báo cáo Z lớp thiết bị - Z gần nhất của thiết bị gọi (vẫn xem được sau drop-out / carry-over). Thiết bị KHÔNG két được cắt Z ngay khi ĐÓNG ca (số liệu đã đóng băng, không phải chờ đối soát) - các trường tiền két = 0, chỉ mang phần doanh số của máy |
GET | /shifts?filter=... | Danh sách ca theo chuẩn LIST chung (loopback filter; merchant qua header x-merchant-id; response Shift[] hoặc {count,data} theo x-request-count-data; backend luôn ép scope merchant) |
GET /shifts/current trả null (chưa vào ca) hoặc { shift, enrollment, drawer?, expectedCash?, deviceParticipation }:
current trả về | Màn hình |
|---|---|
null | Mở ca |
shift.status = 100_OPENING | Bán hàng (+ thao tác két nếu có drawer) |
shift.status = 200_CLOSED và drawer.status = 200_CLOSED | Đối soát két |
shift.status = 200_CLOSED, không có drawer | Chờ ca mới |
expectedCash: chỉ có khi merchant TẮT blind count.deviceParticipation = { activeDeviceCount, joinedDeviceCount, pendingDevices[] }- thiết bị nào của merchant chưa join (kèmcashDrawerEnabledđể biết máy nào còn chờ nhập quỹ).
Bán hàng: gửi x-device-info trên tạo đơn nháp và checkout - backend tự gắn đơn vào ca (openedInShiftEnrollmentId lúc tạo, closedInShiftEnrollmentId + shiftId lúc thanh toán - doanh thu tính cho ca đóng đơn; đơn mở ca A thanh toán ca B được hỗ trợ sẵn) và tự check-in nhân viên thao tác (§4).
Luồng lệch tiền khi reconcile: |thực tế − dự kiến| > ngưỡng merchant → lỗi close.discrepancy_recount_required (409) → dialog đếm lại: gửi số mới hoặc kèm acknowledgeDiscrepancy: true.
7. Cấu trúc báo cáo (X/Z dùng chung)
{
"reportMeta": { "reportType": "Z", "reportNo": 3, "code": "Z-20260611-003" }, // code = mã phiếu bàn giao ca
"sessionInfo": { "id": "...", "openedAt": "ISO", "closedAt": "ISO|null", "cashierId": "...", "closerId": "..." },
"cashSummary": {
"openingFloat": 500000, "cashSales": 4200000, "cashRefunds": 0,
"payIns": 200000, "payOuts": 150000, "safeDrops": 1000000,
"expectedCash": 3750000, "actualCash": 3740000, "cashDiscrepancy": -10000
},
"salesSummary": { "grossSales": 0, "discounts": 0, "taxAmount": 0, "netSales": 0, "orderCount": 0, "itemCount": 0 },
"paymentMethods": { "CASH": 0, "CARD": 0, "TRANSFER": 0 },
"actualNonCash": { },
"sections": { // ma trận tài chính: count = số phiếu, amount = tổng tiền
"sale": { "CASH": { "count": 12, "amount": 4200000 }, "BANK": {}, "QR_CODE": {}, "MOBILE_POS": {} },
"receipt": { }, // phiếu thu khác (PAY_IN, phiếu thu thủ công)
"payment": { }, // phiếu chi khác (PAY_OUT, SAFE_DROP, phiếu chi thủ công)
"return": { } // hoàn trả
},
"notes": "..." // ghi chú thu ngân - chỉ trên Z theo két; null trên X và Z tổng hợp
}- Đối soát một két sinh ra MỘT Z CHO MỖI THIẾT BỊ gắn vào két (không phải 1 Z gộp duy nhất): mỗi Z mang tiền két gộp (giống hệt giữa các thiết bị → cờ phản hồi
cashShared: true) + doanh số RIÊNG của thiết bị đó. Gom theodrawerId, đừng cộng tiền giữa chúng.cashSharedđược suy diễn lúc đọc (két có >1 thiết bị), không lưu DB. - Z tổng hợp toàn ca: các số tiền két (
openingFloat/expectedCash/actualCash/cashDiscrepancy) = 0; doanh số là của cả ca;notes= null. Vẫn là nguồn chân lý về tiền (cộng các két vật lý), độc lập với các dòng Z theo thiết bị. sectionskhông đếm trùng giữa các mục: mỗi phiếu vào đúng MỘT section (phiếu thu từ bán hàng nằm trongsale, không lặp ởreceipt).- Split tender: hoá đơn thanh toán nhiều phương thức được đếm 1 lần ở mỗi phương thức nó chạm -
amountluôn chính xác; KHÔNG cộngcount4 cột để suy ra tổng số hoá đơn.
8. Luồng
Luồng màn hình bàn giao ca (đóng từ màn X-report):
- Nút "Cập nhật dữ liệu mới nhất" →
GET /shifts/{id}/report(toàn ca + từng thiết bị;?deviceId=cho 1 máy) - X live khi đang mở, không ghi bản ghi. - Thu ngân nhập
actualCash+notes. - Nút "Đóng ca":
close→reconcile(xử lý dialog lệch tiền nếu có). - Nút "Đóng ca & in phiếu bàn giao": như trên rồi render phiếu từ response Z (
reportMeta.codelà mã phiếu). - Merchant nhiều két: lặp bước 2-3 từng máy có két; phiếu tổng =
GET /shifts/{id}/report(bundle.shift) sau két cuối. Mỗi máy xem lại Z của mình quaGET /shifts/z-report.
9. Bảng mã lỗi
Key (server.sale.shift.*) | HTTP | Mô tả |
|---|---|---|
management.disabled | 409 | Merchant chưa bật quản lý ca |
actor.user_invalid | 400 | Token không hợp lệ |
actor.device_header_missing | 400 | Thiếu x-device-info |
actor.device_not_found | 404 | deviceId không tồn tại |
actor.device_merchant_unassigned | 400 | Thiết bị chưa gán merchant |
actor.device_merchant_mismatch | 400 | Thiết bị thuộc merchant khác |
actor.merchant_id_missing | 400 | (danh sách ca) thiếu header x-merchant-id |
actor.sale_channel_not_found | 404 | Kênh bán không tồn tại |
actor.sale_channel_merchant_mismatch | 400 | Kênh bán thuộc merchant khác |
open.opening_float_required | 400 | Máy có két phải nhập quỹ đầu ca |
drawer.pending_reconcile | 409 | Mở két có phiên cũ CHỐT-chưa-đối-soát (tiền chưa đếm) - đối soát trước; mở ca MỚI thì không bị chặn |
open.already_open | 409 | Thiết bị đang ở ca khác còn mở |
open.merchant_mismatch | 403 | Ghi danh hiện tại thuộc merchant khác |
lookup.no_active_session | 400 | Thiết bị chưa vào ca (tạo đơn) |
checkout.no_active_session | 409 | Thiết bị chưa vào ca (checkout) |
session.merchant_mismatch | 403 | Ghi danh thuộc merchant khác (guard bán) |
state.shift_not_open | 400 | Ca đã đóng - refresh current, chuyển màn đối soát |
close.discrepancy_recount_required | 409 | Lệch tiền vượt ngưỡng - đếm lại / xác nhận |
drawer.not_open | 400 | Két không mở (thu/chi sau đóng ca) |
drawer.not_found | 404 | Két / báo cáo Z không tồn tại |
z_report.already_generated | 409 | Két đã đối soát rồi |
cash_wallet.not_found | 400 | Merchant chưa có ví tiền mặt mặc định |
scope.merchant_forbidden | 403 | Không có quyền trên merchant của tài nguyên |
device_context.required | 400 | Tạo đơn thiếu device context khi bật quản lý ca |
10. Trang liên quan
- Sale Order - đơn gắn vào ca qua
shiftId/openedInShiftEnrollmentId/closedInShiftEnrollmentId - PRD: Ca Đa nhân viên
- Báo cáo X/Z