Bỏ qua để đến nội dung

Work shifts (Vendor)

Nội dung này hiện chưa có sẵn bằng ngôn ngữ của bạn.

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:

ControllerMountAudience
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.


GoalsNon-goals
Force a real cash count at start and end of every cashier sessionBe 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 reconciliationMerge work-shift and attendance into one entity
Block double-open on the same checkout laneAllow mid-shift drawer transfers without close (planned, see Known limitations)
Carry opening counts (cash, invoice, voucher, card slips) from previous shiftTrack operations of admin / customer apps

TableKey columnsPurpose
vendor_shift_templatesid, 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_shiftsid, 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 shiftLive shift session row
vendor_work_shift_assigneesvendor_work_shift_id, vendor_employee_idExtra assignee. Capped at 1 by current rules (see below)
store_pos_registersid, store_id, nameCheckout 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.


ActorTemplates CRUDList shifts (any)List shifts (own)Open selfOpen + assign someoneReassign open shiftClose shiftForce-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).


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 &amp; 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]

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):

  1. One open shift per (store, pos_register_id). Any second open attempt → 400 shift_open.
  2. Single assignee. assigned_vendor_employee_ids has @ArrayMaxSize(1); employees must send [] (only owner can pre-assign).
  3. Lane lock. If the JWT employee has a fixed posRegisterId on session, the requested pos_register_id must match → 403 pos_register otherwise.
  4. 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).
  5. Warehouse must belong to store (when supplied).
  6. 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.ensureClockInForEmployees punches in for:
    • the employee opener (self), or
    • owner-assigned assigned_vendor_employee_ids. Idempotent while already punch_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.

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.

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.

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, sets closed_by_vendor_employee_id from the JWT, stamps closed_at.
  • Visibility is re-checked: a non-owner without settings write 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), ensureClockOutForEmployees punches 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.

Detail endpoint computes order_stats.closing_slip_suggestions:

closing_invoice_slip_count = opening_invoice_slip_count + paid_order_count
closing_voucher_stub_count = opening_voucher_stub_count + paid_orders_with_coupon_count
closing_card_receipt_count = opening_card_receipt_count + paid_orders_with_card_payment_count

Where 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.

BasisMeaning
drawerCashier physically hands the drawer (and counted cash) to the next shift. cash_handover_amount = whatever stays in the drawer.
ledgerCash 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.


MethodPathPurpose
GET/api/v1/vendor/shifts/templatesList shift templates per store
POST/api/v1/vendor/shifts/templatesCreate 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}/assigneesReplace template suggested members (clears + inserts vendor_shift_template_assignees)
GET/api/v1/vendor/shifts/work-shiftsList shifts (filters: store_id, status, pos_register_id, date range, mine)
GET/api/v1/vendor/shifts/work-shifts/currentCurrent open shift for (store_id, pos_register_id?, mine?)
GET/api/v1/vendor/shifts/work-shifts/last-closedLast 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-shiftsOpen shift
PATCH/api/v1/vendor/shifts/work-shifts/{id}/assigneesReplace single assignee
PATCH/api/v1/vendor/shifts/work-shifts/{id}/closeClose shift

/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 catalogAuth (vendor) — context reads.


RouteWrapsNotes
/dashboard/orders/shiftsProtectedRouteListing with status / date / lane filters and store selector
/dashboard/orders/shifts/templatesProtectedRoute (settings)Template CRUD
/dashboard/orders/shifts/lanesProtectedRoute (settings)Manage store_pos_registers
/dashboard/orders/shifts/:shiftIdProtectedRouteDetail + close modal
/pos, /dashboard/orders/createSurface 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/*.


HTTPcodeWhen
400shift_openAn open shift already exists for (store, pos_register_id)
400pos_registerEmployee tried to query current and the store has multiple open lanes — must pass pos_register_id
400assigneesMore than one employee id passed at open or assignees endpoint
400cash_handover_basisUnknown value (must be drawer or ledger)
403assigneesEmployee tried to pre-assign someone at open
403pos_registerEmployee with fixed posRegisterId tried a different lane
403forbiddenNon-owner without settings write tried to close someone else’s shift
404Shift not found / not visible to the actor

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 in pos.service.spec.ts.

  1. Owner opens with assignee — succeeds; assignee row created.
  2. Employee opens self with assigned_vendor_employee_ids: ['x']403 assignees.
  3. Second open at same (store, pos_register_id)400 shift_open.
  4. Employee with posRegisterId: 7 opens lane 8403 pos_register.
  5. POS place-order without vendor_work_shift_id, employee has 1 open shift on the lane — order auto-attributed to that shift.
  6. POS place-order with vendor_work_shift_id belonging to closed shift400.
  7. PATCH /:id/assignees with 0 ids400 (DTO @ArrayMinSize(1)).
  8. Reassign by random employee (not opener / current assignee)403.
  9. Close with cash_handover_basis: 'drawer' and actual_cash differing from suggestion — accepted; values stored as supplied (no automatic adjustment).
  10. Open second shift after close with carry_from_previous: trueopening_cash / slip counts inherit from the just-closed shift.
  11. Slip suggestions for shift with 5 paid orders (2 with coupon, 3 by card)+5, +2, +3 to opening counts.
  12. E2E for fallback path: employee without settings lists /auth/vendor/context/shifts/work-shifts/current — sees only own open shift.

  1. No “remove assignee”. PATCH /:id/assignees requires exactly one id. To “unassign” without close, the only option today is to swap to another staff member.
  2. No mid-shift handover (split). Two cashiers sharing one drawer across a hand-off must close + reopen. A future POST /:id/handover could record the handover without resetting cash/slips.
  3. Error code pos_register is 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.