Skip to content

Ca làm việc (Shift v2)

1. Tổng quan

Thuộc tínhGiá trị
IDFEAT-SALE-SHIFT
Trạng tháiStable (v2 - thay thế hoàn toàn PosSession v1)
Ownersale-team
Phụ thuộcDevice, SaleChannel, SaleOrder (shiftId/openedInShiftEnrollmentId/closedInShiftEnrollmentId), FinanceVoucher (shiftId), FinanceTransaction (shiftEnrollmentId)
PRDPRD-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ể

  • ShiftEnrollmentđa hình: principalType = 'Device' | 'User', principalId = deviceId hoặc userId. Mỗi principal chỉ có một ghi danh 201_ACTIVATED tạ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ượngGiá trịÝ nghĩa
Shift100_OPENINGĐang mở - bán hàng bình thường
Shift200_CLOSEDĐã đóng - KHÓA bán hàng, chờ đối soát két
Shift300_RECONCILEDĐã đối soát đủ - có Z tổng hợp
Drawer100_OPENING / 200_CLOSED / 300_RECONCILEDTươ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 OPENING cù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/PARTIAL re-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ằng drawer.pending_reconcile cho tới khi két đó được đối soát (hiển thị qua pendingReconcile/drawer-devices).
  • Ca không có két nào → chốt thẳng RECONCILED ngay 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ểmHành vi
Nhân viên mở/join caGhi 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ácTự 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 = enrolledAtleftAt trên bản ghi danh USER; dòng thời gian chi tiết nằm ở event ledger.

Event ledger (ShiftEnrollmentEvent.type):

EventPrincipalÝ nghĩa
DROP_IN / DROP_OUTDeviceThiết bị vào / rời ca (drop-out chọn lúc reconcile)
CHECK_IN / CHECK_OUTUserNhân viên vào / rời ca
DRAWER_OPENDeviceMở két (amount = quỹ đầu ca)
PAY_IN / PAY_OUT / SAFE_DROPDeviceThu / chi / rút cất két (amount bắt buộc; sinh phiếu thu/chi kế toán)
RECONCILEDeviceĐối soát két (amount = tiền đếm thực tế, note = ghi chú thu ngân)

5. Xác thực & Header

HeaderBắt buộcGhi chú
Authorization: Bearer <JWT>Tất cả endpointDanh 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}/joinmanagement.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

VerbPathMục đích
GET/shifts/currentTrạng thái thiết bị - nguồn điều hướng màn hình duy nhất
POST/shiftsTạo ca - { saleChannelId, name?, notes?, drawerDeviceId?, openingFloat?, startAt?, endAt? }. name/notes là i18n { default, en?, vi? }
POST/shifts/{id}/joinTham gia ca đang mở - { drawerDeviceId?, openingFloat?, notes? } (notes i18n)
POST/shifts/cash-movementThu/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}/reportBundle { 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/snapshotLư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-reportBá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
nullMở ca
shift.status = 100_OPENINGBán hàng (+ thao tác két nếu có drawer)
shift.status = 200_CLOSEDdrawer.status = 200_CLOSEDĐối soát két
shift.status = 200_CLOSED, không có drawerChờ 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èm cashDrawerEnabled để 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)

jsonc
{
  "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 theo drawerId, đừ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ị.
  • sections khô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 trong sale, 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 - amount luôn chính xác; KHÔNG cộng count 4 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):

  1. 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.
  2. Thu ngân nhập actualCash + notes.
  3. Nút "Đóng ca": closereconcile (xử lý dialog lệch tiền nếu có).
  4. Nút "Đóng ca & in phiếu bàn giao": như trên rồi render phiếu từ response Z (reportMeta.code là mã phiếu).
  5. 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 qua GET /shifts/z-report.

9. Bảng mã lỗi

Key (server.sale.shift.*)HTTPMô tả
management.disabled409Merchant chưa bật quản lý ca
actor.user_invalid400Token không hợp lệ
actor.device_header_missing400Thiếu x-device-info
actor.device_not_found404deviceId không tồn tại
actor.device_merchant_unassigned400Thiết bị chưa gán merchant
actor.device_merchant_mismatch400Thiết bị thuộc merchant khác
actor.merchant_id_missing400(danh sách ca) thiếu header x-merchant-id
actor.sale_channel_not_found404Kênh bán không tồn tại
actor.sale_channel_merchant_mismatch400Kênh bán thuộc merchant khác
open.opening_float_required400Máy có két phải nhập quỹ đầu ca
drawer.pending_reconcile409Mở 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_open409Thiết bị đang ở ca khác còn mở
open.merchant_mismatch403Ghi danh hiện tại thuộc merchant khác
lookup.no_active_session400Thiết bị chưa vào ca (tạo đơn)
checkout.no_active_session409Thiết bị chưa vào ca (checkout)
session.merchant_mismatch403Ghi danh thuộc merchant khác (guard bán)
state.shift_not_open400Ca đã đóng - refresh current, chuyển màn đối soát
close.discrepancy_recount_required409Lệch tiền vượt ngưỡng - đếm lại / xác nhận
drawer.not_open400Két không mở (thu/chi sau đóng ca)
drawer.not_found404Két / báo cáo Z không tồn tại
z_report.already_generated409Két đã đối soát rồi
cash_wallet.not_found400Merchant chưa có ví tiền mặt mặc định
scope.merchant_forbidden403Không có quyền trên merchant của tài nguyên
device_context.required400Tạo đơn thiếu device context khi bật quản lý ca

10. Trang liên quan

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