Work shifts (Vendor)
Nội dung này hiện chưa có sẵn bằng ngôn ngữ của bạn.
Executive summary
Section titled “Executive summary”Work shifts is the vendor-side cashier session model: each open shift binds one store + one checkout lane (pos_register_id / két) + one staff member (opener, with at most one extra assignee) and acts as the attribution target for orders placed via POS or /dashboard/orders/create. Closing a shift records cash count, slip counts, and a handover basis (drawer or ledger) for end-of-day reconciliation.
The dashboard path /dashboard/orders/shifts is My Availability (weekly preferred/unavailable grid). The POS work-shift history UI lives at /dashboard/orders/shifts/v1. See My Availability.
Two backend controllers serve the same service:
| Controller | Mount | Audience |
|---|---|---|
VendorShiftsController | /api/v1/vendor/shifts/* | Owner; or employee with settings module read/write (templates, lanes, list, force-open with assignee) |
VendorAuthContextController (fallback) | /api/v1/auth/vendor/context/shifts/* | Any authenticated vendor JWT — read templates/list/current/last-closed; employees can also open self-only and close own without settings |
Both routes go through the same VendorShiftsService rules — the controllers only change which mutations are exposed.
Goals and non-goals
Section titled “Goals and non-goals”| Goals | Non-goals |
|---|---|
| Force a real cash count at start and end of every cashier session | Be a full timesheet / payroll tracker (work shifts link to attendance punches; they are not the same table) |
| Tie POS / dashboard orders to the right cashier + lane for end-of-day reconciliation | Merge work-shift and attendance into one entity |
| Block double-open on the same checkout lane | Allow mid-shift drawer transfers without close (planned, see Known limitations) |
| Carry opening counts (cash, invoice, voucher, card slips) from previous shift | Track operations of admin / customer apps |
Domain model
Section titled “Domain model”| Table | Key columns | Purpose |
|---|---|---|
vendor_shift_templates | id, store_id, name, time_start, time_end, sort_order, is_active, effective_on (first calendar day the template applies) | Reusable shift presets (e.g. Morning, Afternoon) per store; weekday windows live on vendor_availability_slots.shift_template_id |
vendor_work_shifts | id, vendor_id, store_id, pos_register_id, warehouse_id, template_id?, shift_label, status (open/closed), opening_*, closing_*, cash_count_detail, opened_by_vendor_employee_id, closed_by_vendor_employee_id, opened_at, closed_at, optional terminal_batch_closed_at (+ device + closer employee ids) when a terminal settlement batch was closed with the shift | Live shift session row |
vendor_work_shift_assignees | vendor_work_shift_id, vendor_employee_id | Extra assignee. Capped at 1 by current rules (see below) |
store_pos_registers | id, store_id, name | Checkout lane / cash drawer. The pos_register_id on a shift points here |
opened_by_vendor_employee_id always exists (the opener). assignees can be 0 (employee opening self) or 1 (owner opens for one staff member) — never more.
Source-of-truth code anchors:
apps/backend/src/database/entities/vendor-work-shift.entity.ts,apps/backend/src/modules/vendor/vendor-shifts.service.ts,apps/backend/src/modules/vendor/dto/vendor-shifts.dto.ts.
RBAC matrix
Section titled “RBAC matrix”| Actor | Templates CRUD | List shifts (any) | List shifts (own) | Open self | Open + assign someone | Reassign open shift | Close shift | Force-close any shift |
|---|---|---|---|---|---|---|---|---|
| Vendor owner | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (any) | ✅ (any) | ✅ |
Employee w/ settings write | ✅ | ✅ | ✅ | ✅ | ❌ (only owner can assign at open) | ✅ if opener / current assignee | ✅ if opener / assignee | ❌ |
Employee w/ settings read | ❌ | ✅ | ✅ | ❌ on /vendor/shifts/*; ✅ via fallback | ❌ | ❌ | ✅ if opener / assignee | ❌ |
Employee w/o settings | ❌ | ❌ | ✅ via fallback /auth/vendor/context/shifts/* | ✅ via fallback | ❌ | ❌ | ✅ via fallback if opener / assignee | ❌ |
vendorRequestUserCanManageOrderWorkShifts (vendor-role-modules.util.ts) treats owner or employee with order write as able to manage store-wide shift operations the UI exposes (e.g. force-open dialogs).
Lifecycle
Section titled “Lifecycle”flowchart TB
A[GET /work-shifts/current<br/>?store_id&pos_register_id] -->|no open shift| B[POST /work-shifts<br/>open]
A -->|open shift exists| OPS
B --> OPS
OPS[Operating: POS & manual orders<br/>auto-attribute via vendor_work_shift_id] --> R{Reassign?}
R -->|owner / current assignee| ASG[PATCH /:id/assignees<br/>exactly 1 employee_id]
R -->|no| OPS
OPS --> CLOSE[PATCH /:id/close<br/>cash_count + slips + basis]
CLOSE --> H[Last-closed becomes carry source<br/>for next open at same lane]
1. Open
Section titled “1. Open”POST /api/v1/vendor/shifts/work-shifts (or /auth/vendor/context/shifts/work-shifts for employees without settings).
Required: store_id, pos_register_id, shift_label, opening_cash, opening_invoice_slip_count, opening_voucher_stub_count, opening_card_receipt_count. Optional: template_id, warehouse_id, cash_handover_basis, cash_count_detail, notes, assigned_vendor_employee_ids (owner only, max 1), carry_from_previous: true to inherit opening counts from the most recent closed shift on the same (store, pos_register, warehouse?).
Hard rules enforced server-side (VendorShiftsService.openWorkShift):
- One open shift per
(store, pos_register_id). Any second open attempt →400 shift_open. - Single assignee.
assigned_vendor_employee_idshas@ArrayMaxSize(1); employees must send[](only owner can pre-assign). - Lane lock. If the JWT employee has a fixed
posRegisterIdon session, the requestedpos_register_idmust match →403 pos_registerotherwise. - No double-staffing. The opener (and any pre-assigned employee) cannot already be the opener / assignee of another open shift on the same
(store, pos_register_id). - Warehouse must belong to store (when supplied).
- Timecard clock-in (linked, separate entity). Work shifts (
vendor_work_shifts) and timecards (vendor_attendance_days+vendor_attendance_punches) stay separate. After the shift (and assignees) are saved,VendorAttendanceService.ensureClockInForEmployeespunches in for:- the employee opener (self), or
- owner-assigned
assigned_vendor_employee_ids. Idempotent while alreadypunch_state = in. After a prior clock-out the same store calendar day, opening another shift adds another punch-in segment (multi-shift / day). Labels:work_shift_open/work_shift_join.
2. Operate — order attribution
Section titled “2. Operate — order attribution”POS and /dashboard/orders/create both pass vendor_work_shift_id on place-order payloads. PosService.applyPlaceOrderWorkShiftAttribution also auto-attributes when the field is missing: it looks up the actor’s most recent open shift at the same (store, pos_register_id) and stamps it on the order.
PosService.validateVendorOrderAttribution rejects payloads where vendor_work_shift_id is closed, belongs to another store, or its pos_register_id does not match the actor’s lane.
3. Reassign — PATCH /:id/assignees
Section titled “3. Reassign — PATCH /:id/assignees”DTO: assigned_vendor_employee_ids with @ArrayMinSize(1) + @ArrayMaxSize(1) — exactly one employee id.
- Owner can always swap.
- Employee can swap only if they are the current opener or current assignee.
- New assignee must not already opener / assignee on another open shift at the same
(store, pos_register_id).
⚠️ There is no “remove assignee” path today — once an assignee is set, the shift always carries one. See Known limitations.
4. Close — PATCH /:id/close
Section titled “4. Close — PATCH /:id/close”Required: closing_actual_cash, closing_invoice_slip_count, closing_voucher_stub_count, closing_card_receipt_count, closing_handover_basis (drawer | ledger), closing_handover_amount. Optional: closing_expected_cash, cash_count_detail, handover_recipient_name, closing_notes.
- Status flips
open → closed, setsclosed_by_vendor_employee_idfrom the JWT, stampsclosed_at. - Visibility is re-checked: a non-owner without
settingswrite must be opener or assignee. - After close, the row becomes the carry source for the next open on the same lane (if
carry_from_previous: true). - Timecard clock-out: for each participant (opener ∪ assignees), if they have no other open work shift at the same store (any lane),
ensureClockOutForEmployeespunches out (device_label = work_shift_close). If they still have another open lane session, the day timecard stays punched in. Production minutes sum all in/out punch pairs for the day.
Optional payment-terminal batch close (per shift, staff-attributed): send close_payment_terminal_batch: true and payment_terminal_device_id (a saved store_devices row for this store, type payment_terminal). The server dispatches BATCH_CLOSE to that device via the POS bridge before saving the closed shift. If the terminal rejects batch close, the entire close request fails (shift stays open). On success, the shift row stores terminal_batch_closed_at, indochina_terminal_batch_payment_device_id, and indochina_terminal_batch_closed_by_vendor_employee_id (when the closer is an employee; owners may leave the latter null).
POS guard: for paid card orders tied to a shift (orders.indochina_vendor_work_shift_id), orderTerminalPostCapture (void sale / tip adjustment) returns 400 terminal_batch_closed once that shift has a non-null terminal_batch_closed_at, because the manufacturer batch is already settled.
The work-shift detail response includes payment_terminal_devices (enabled terminals for the store) so the close-shift UI can offer a device picker without requiring the settings module.
Slip suggestions (GET /:id)
Section titled “Slip suggestions (GET /:id)”Detail endpoint computes order_stats.closing_slip_suggestions:
closing_invoice_slip_count = opening_invoice_slip_count + paid_order_countclosing_voucher_stub_count = opening_voucher_stub_count + paid_orders_with_coupon_countclosing_card_receipt_count = opening_card_receipt_count + paid_orders_with_card_payment_countWhere paid means LOWER(orders.payment_status) = 'paid'. Pending / partial orders attributed to the shift do not affect suggestions — only the actual closing_* numbers staff submit at close. The detail also returns payment_breakdown (by normalized bucket: Cash, Card, Bank transfer, Other) and staff_breakdown for hand-off review.
Cash handover basis
Section titled “Cash handover basis”| Basis | Meaning |
|---|---|
drawer | Cashier physically hands the drawer (and counted cash) to the next shift. cash_handover_amount = whatever stays in the drawer. |
ledger | Cash is removed and only a ledger / safe-drop record carries forward. cash_handover_amount = amount left for next opening. |
The vendor settings page (/dashboard/settings) controls store-level cash denominations used by the open / close modals to render the count-by-denomination grid (parsePosCashDenominationsFromDashboardPrefs). Denominations are stored as comma-separated integers in the smallest currency unit (USD: cents). New store_configurations rows default to USD ($, symbol-before) with standard US bill/coin cent values; existing stores keep their saved currency until edited on the store configuration screen.
API endpoints
Section titled “API endpoints”Primary (settings module guard)
Section titled “Primary (settings module guard)”| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/vendor/shifts/templates | List shift templates per store |
| POST | /api/v1/vendor/shifts/templates | Create template |
| PATCH | /api/v1/vendor/shifts/templates/{id} | Update template |
| DELETE | /api/v1/vendor/shifts/templates/{id} | Delete template |
| PATCH / PUT | /api/v1/vendor/shifts/templates/{id}/assignees | Replace template suggested members (clears + inserts vendor_shift_template_assignees) |
| GET | /api/v1/vendor/shifts/work-shifts | List shifts (filters: store_id, status, pos_register_id, date range, mine) |
| GET | /api/v1/vendor/shifts/work-shifts/current | Current open shift for (store_id, pos_register_id?, mine?) |
| GET | /api/v1/vendor/shifts/work-shifts/last-closed | Last closed shift for handover defaults |
| GET | /api/v1/vendor/shifts/work-shifts/{id} | Detail with order_stats, assignees, pos_cash_denominations, payment_terminal_devices, slip suggestions |
| POST | /api/v1/vendor/shifts/work-shifts | Open shift |
| PATCH | /api/v1/vendor/shifts/work-shifts/{id}/assignees | Replace single assignee |
| PATCH | /api/v1/vendor/shifts/work-shifts/{id}/close | Close shift |
Fallback (any vendor JWT)
Section titled “Fallback (any vendor JWT)”/api/v1/auth/vendor/context/shifts/* mirrors read + open self + close own for employees who do not have the settings module. See full list in Backend API catalog → Auth (vendor) — context reads.
UI routes
Section titled “UI routes”| Route | Wraps | Notes |
|---|---|---|
/dashboard/orders/shifts | ProtectedRoute | Listing with status / date / lane filters and store selector |
/dashboard/orders/shifts/templates | ProtectedRoute (settings) | Template CRUD |
/dashboard/orders/shifts/lanes | ProtectedRoute (settings) | Manage store_pos_registers |
/dashboard/orders/shifts/:shiftId | ProtectedRoute | Detail + close modal |
/pos, /dashboard/orders/create | — | Surface the current shift label and lane on the place-order payload |
useTranslation keys live under pos.shift.* and dashboard.shifts.* in apps/vendor-web/src/locales/*.
Error code reference
Section titled “Error code reference”| HTTP | code | When |
|---|---|---|
| 400 | shift_open | An open shift already exists for (store, pos_register_id) |
| 400 | pos_register | Employee tried to query current and the store has multiple open lanes — must pass pos_register_id |
| 400 | assignees | More than one employee id passed at open or assignees endpoint |
| 400 | cash_handover_basis | Unknown value (must be drawer or ledger) |
| 403 | assignees | Employee tried to pre-assign someone at open |
| 403 | pos_register | Employee with fixed posRegisterId tried a different lane |
| 403 | forbidden | Non-owner without settings write tried to close someone else’s shift |
| 404 | — | Shift not found / not visible to the actor |
Test scenarios
Section titled “Test scenarios”Backend tests live next to each file:
vendor-shifts.service.spec.ts,vendor-shifts.controller.spec.ts,vendor-auth-context.controller.spec.ts, plus targeted POS attribution tests inpos.service.spec.ts.
- Owner opens with assignee — succeeds; assignee row created.
- Employee opens self with
assigned_vendor_employee_ids: ['x']—403 assignees. - Second open at same
(store, pos_register_id)—400 shift_open. - Employee with
posRegisterId: 7opens lane 8 —403 pos_register. - POS place-order without
vendor_work_shift_id, employee has 1 open shift on the lane — order auto-attributed to that shift. - POS place-order with
vendor_work_shift_idbelonging to closed shift —400. PATCH /:id/assigneeswith 0 ids —400(DTO@ArrayMinSize(1)).- Reassign by random employee (not opener / current assignee) —
403. - Close with
cash_handover_basis: 'drawer'andactual_cashdiffering from suggestion — accepted; values stored as supplied (no automatic adjustment). - Open second shift after close with
carry_from_previous: true—opening_cash/ slip counts inherit from the just-closed shift. - Slip suggestions for shift with 5 paid orders (2 with coupon, 3 by card) —
+5,+2,+3to opening counts. - E2E for fallback path: employee without
settingslists/auth/vendor/context/shifts/work-shifts/current— sees only own open shift.
Known limitations
Section titled “Known limitations”- No “remove assignee”.
PATCH /:id/assigneesrequires exactly one id. To “unassign” without close, the only option today is to swap to another staff member. - No mid-shift handover (split). Two cashiers sharing one drawer across a hand-off must close + reopen. A future
POST /:id/handovercould record the handover without resetting cash/slips. - Error code
pos_registeris overloaded — used both for missing (400) and mismatch (403). Frontend currently inspects HTTP status to disambiguate.
See Fix plan in the engineering tracker for the proposed changes.
Related
Section titled “Related”- Sales, POS & receivables (procedures) — POS lane requirement (
pos.laneRequiredBeforeOrder) and the procedural side of cashier sessions. - POS — checkout flow that consumes the open shift.
- Orders (Vendor) — how attributed orders surface in lists / detail.
- Backend API catalog — full endpoint list (primary + fallback).