POS local-first (desktop)
Nội dung này hiện chưa có sẵn bằng ngôn ngữ của bạn.
POS local-first (vendor desktop)
Section titled “POS local-first (vendor desktop)”Phased rollout to reduce checkout latency for card payments and improve receipt behavior on Lion POS desktop.
Feature flags (renderer localStorage)
Section titled “Feature flags (renderer localStorage)”| Key | Phase | Default (Electron) |
|---|---|---|
vendor_pos_local_ecr | 1 — local EcrHub | on |
vendor_pos_local_db | 2 — SQLite | on |
vendor_pos_local_place_order | 3 — order outbox | on (no manual setup) |
Set to 0 / false to fall back to server bridge relay + polling.
Phase 1 — Local ECR
Section titled “Phase 1 — Local ECR”POST /vendor/point-of-sale/orderswithterminal_payment_context.client_ecr_dispatch: truecreates the order but does not enqueueecr_relay_requeston the server bridge.- Renderer calls
window.electron.ecrDispatch→ main processEcrHub→ PAX TCP or CodePay WS. - Renderer posts
POST .../orders/:id/terminal-client-capturewith theEcrHubResultJSON. - Server applies the same webhook path as bridge completion (
handleTerminalPaymentWebhook).
Phase 2 — Local SQLite
Section titled “Phase 2 — Local SQLite”Database file: {userData}/pos-local/pos.sqlite (sql.js embedded SQLite in Electron main — no native addon)
catalog_cache— POS catalog snapshots plus lane/register list, device settings, store configuration, and current open work shift (keyed by store + cache key).orders_outbox— place-order payloads for retry / audit;settingsrows queue open work shift while offline.
IPC: posLocalDb:* via preload (see apps/vendor-web/src/lib/electron.ts).
Reset local data (settings)
Section titled “Reset local data (settings)”Cashiers with Settings access can open Point of sale → Settings → Local data (desktop only). The flow:
- WAL checkpoint and copy
pos.sqlite(+-wal/-shmwhen present) to{userData}/pos-local/backups/{appVersion}-{timestamp}/with amanifest.json. - Append a JSON line to
{userData}/pos-local/operations.jsonl(action: reset_local_data, store id, backup version, row counts). - Delete
catalog_cacherows for the current store (always). Optionally deleteorders_outboxrows for that store when the user chooses Also clear order outbox. - Renderer invalidates TanStack Query, re-hydrates from SQLite, and pulls catalog from the server when online.
IPC channel: posLocalDb:resetLocalData → preload posLocalDbResetLocalData.
Phase 3 — Place-order outbox
Section titled “Phase 3 — Place-order outbox”Before HTTP place-order, the payload is written to orders_outbox. On success the row is marked synced with server_order_id; on failure failed with last_error.
Dual-key strategy (system id + display id)
Section titled “Dual-key strategy (system id + display id)”| Key | Where | Purpose |
|---|---|---|
| System id | orders_outbox.id (UUID) + client_order_id on HTTP push | Idempotent sync, foreign keys, terminal numeric refs |
| Display id | orders_outbox.order_code + local_order_code in payload metadata | Human-readable receipt / orders list while offline |
Offline display codes use {store_prefix}-{register_prefix}-{YYMMDD}-{daily_seq} (e.g. PBH-Q1-260526-0001). Prefixes come from cached store document rules (sales) and POS register code. The daily counter is stored per store + lane in catalog_cache (sync:order_display_counter:{yymmdd}:{register}) inside a SQLite transaction; allocation also seeds from existing orders_outbox.order_code rows and skips any code already taken locally.
On sync, the server still allocates its own orders.entity_key via allocateStoreDocumentNumber (authoritative for finance). The local display code remains on the outbox row for audit until server_order_id is set.
Hybrid outbox row (orders_outbox)
Section titled “Hybrid outbox row (orders_outbox)”| Column | Role |
|---|---|
id | System UUID (primary key) |
order_code | Denormalized display id for list/search |
total_amount | Denormalized cart total |
payload_json | Full place-order JSON (raw_payload — server normalizes on push) |
needs_sync / status | Push queue state |
Offline write = one INSERT with flat index fields + serialized cart JSON. No local auto-increment order ids.
Local id vs server id
Section titled “Local id vs server id”| Field | Meaning |
|---|---|
id | Local outbox UUID — generated on device before any server response |
order_code | Human display id — allocated locally before sync |
server_order_id | Server orders.id — set when sync succeeds |
status | pending → synced or failed |
needs_sync | 1 while the row must be pushed; cleared (0) after a successful sync. Set again when the payload is updated before sync |
created_at / updated_at | Row lifecycle timestamps (ISO-8601) |
synced_at | When the server accepted the order (null while pending/failed) |
place_api | Which HTTP endpoint will retry the row: pos_checkout, vendor_retail, or vendor_wholesale |
Store-level sync metadata in catalog_cache key sync:meta includes pulledAt (last catalog pull), ordersPushedAt (last outbox push attempt), and resourceVersions (per-slice server updated_at stamps from catalog-sync-versions).
IPC posLocalDb:updateOutboxPayload replaces the JSON payload on pending/failed rows and sets needs_sync = 1 (renderer: maybeUpdatePosPlaceOrderOutboxPayload).
Push worker lists rows with needs_sync = 1 (oldest first), not only status = 'pending'.
The renderer shows checkout success immediately from the cart snapshot; background finalize syncs receipt metadata without blocking the success screen.
Orders list metadata (desktop-only)
Section titled “Orders list metadata (desktop-only)”Place-order payloads may include __pos_local_list_meta (stripped before HTTP retry). It stores display fields for the POS orders table while a row is still pending or failed:
- Customer name / email / id from checkout selection
- Salesperson (logged-in cashier)
payment_status:paidfor cash, card, wallet, transfer, etc.;unpaidfor draft / debt- Line count and note
The orders page merges outbox rows with the server list so payment shows Paid when money was collected locally, even if sync is still pending or failed. Sync status stays separate (pending / synced / failed).
Why a sale might not appear on Orders
| Situation | What you should see |
|---|---|
| Just checked out (cash/wallet, online) | Outbox row at top until push finishes, then server row after vendor-orders refetch |
| Offline / push pending | Outbox row with Not synced (needs vendor_pos_local_db + same store in header) |
| Date filter | Outbox created_at is matched on local calendar day (same as the date picker), not UTC YYYY-MM-DD slice |
| Status tab Draft / Cancelled | Only submit_as: draft or server tabs apply; normal checkout uses All or Completed |
| Row already synced | Outbox hidden (needs_sync = 0); order must appear from GET /vendor/orders |
After checkout, the sale screen invalidates pos-local-outbox-orders and, when online, runs pushPendingPosLocalOutbox then invalidates vendor-orders so the server list updates.
Delete local-only orders (POS orders list)
On the POS Order list, long-press a row that is still not synced (needs_sync = 1, no server_order_id) to open a confirm dialog. Confirming runs posLocalDb:deleteOutbox, which removes the SQLite outbox row only when it has not synced. Use this to discard duplicate or mistaken local checkouts without affecting server orders. Legacy browser localStorage offline rows (when local DB is off) are removed the same way from device storage.
Minimal outbox row (SQLite orders_outbox)
| Field | Example | Role |
|---|---|---|
id | UUID | client_order_id on HTTP push (idempotency) |
store_id | store pk | Must match POS store selector |
order_code | PBH-Q1-260526-0001 | Display id in list / receipt while offline |
total_amount | 150000 | Denormalized total for list |
payload_json | see below | Full PlacePosOrderPayload + desktop-only keys |
place_api | pos_checkout | Retry target: POST /vendor/point-of-sale/orders |
needs_sync | 1 | Listed on Orders until push succeeds |
payload_json (place order) — required for server sync
Stripped before HTTP (peelOutboxPlacePayloadForServer): __outbox_place_api, __pos_local_list_meta, local_order_code.
Sent to server on push (added in pos-local-sync-push.ts):
client_order_id= outboxidsync_source=offline_pushclient_device_id= lane device when known
Core body (same as online checkout): store_id, paid_amount, payment_method, cart[], tax, discount, order_type (pos for retail sale workspace), submit_as, optional customer_id, vendor_pos_register_id, terminal_payment_context, invoice, etc. See PlacePosOrderPayload in apps/vendor-web/src/services/pos.api.ts.
Desktop-only __pos_local_list_meta (for Orders table before sync): customer_name, salesperson_name, payment_status (paid | unpaid), line_count, local_order_code.
Push processes one outbox row at a time (oldest first), not a single batch payload.
Server database (Postgres)
Section titled “Server database (Postgres)”After each sync cycle the desktop reports state to the API; place-order retries send the outbox UUID for idempotency.
| Server artifact | Purpose |
|---|---|
orders.pos_client_order_id | Same compact key as outbox client_order_id (≤16 chars, derived from orders_outbox.id); unique per store_id |
orders.pos_client_device_id | Lane / store_devices.id from the client |
orders.pos_sync_source | online or offline_push |
orders.pos_client_synced_at | When the server accepted an offline push |
pos_device_sync_states | Per store_id + device_key: catalog/orders/customers pull & push timestamps, pending/failed outbox counts |
API:
POST /vendor/point-of-sale/sync-state— upsert device sync row (renderer:reportPosLocalSyncToServer)GET /vendor/point-of-sale/sync-state?device_key=— read last report- Place-order body fields:
client_order_id,client_device_id,sync_source— duplicate push returns existingorder_id(idempotent: true)
Run migration 1783700000000-PosLocalSyncServerTracking before deploying backend + desktop.
Phase 4 — Receipt copies + cut
Section titled “Phase 4 — Receipt copies + cut”- Store receipt mode 3 (both) prints two HTML jobs (merchant + customer) per enabled receipt printer.
- Device receipt mode 0 means inherit store default (fixes merchant-only when store is set to both).
- After silent HTML print, desktop sends ESC/POS partial cut via
lp -o raw(same path as cash drawer pulse). - When two jobs target the same printer, a short delay separates merchant and customer copies.
Surfaces
Section titled “Surfaces”Local ECR + outbox are wired on:
vendor-pos-sale-workspace(main POS sale)app/dashboard/pos/page.tsx(multi-tab POS)features/orders/create-order-page.tsx(retail/wholesale create order; local ECR only, no outbox)
Pending outbox rows sync on a 60 second background loop in the POS shell (PosLocalSyncWorker in point-of-sale-app-shell.tsx):
- Push — submit pending
orders_outboxrows (place_order,return) to the server. - Pull — refresh local SQLite
catalog_cachefor catalog + settings. Before downloading, the client callsGET /vendor/point-of-sale/catalog-sync-versions?store_id=and compares each slice’s serverupdated_atwithsync:meta.resourceVersionsin SQLite; unchanged slices are skipped. Store configuration (catalog:store_settings), devices (settings:devices), registers, items, customers, categories, units (pos-custom-itemscope), customer-display banners (catalog:customer_display_banners,SECONDSCREENtitle filter), warehouses, and retail price lists participate; promotions and work-shift snapshots still refresh every cycle.
On POS shell mount, PosLocalSettingsProvider hydrates TanStack Query from SQLite before page queries run so receipt prefs, devices, registers, and open shift match the last sync even when offline.
Lane / shift offline behavior
- Saved checkout lane (
vendor-pos-lanezustand per store) is used as soon as register rows exist in cache — lane auto-select no longer waits for a successful network refetch. - Employee work-shift gate reads cached current shift when offline; opening a shift offline writes a local shift snapshot +
settingsoutbox row, then syncs on the next push cycle.
Local-first service wrappers live in apps/vendor-web/src/services/pos-local-data.api.ts (fetch*LocalFirst). Pages should call these instead of raw HTTP helpers when reading POS data on Electron.
“Online” means API reachable
POS does not use navigator.onLine. The renderer probes GET /api/v1/health on an interval (usePosServerOnline). Slow or failing business APIs still count as offline after consecutive probe failures, while cached SQLite data remains visible.
Catalog list pages (items, customers, barcode) read SQLite first, paint immediately, then refresh from the server when probes succeed.
When local DB is enabled, list/table load error banners are suppressed for catalog and order lists that already have SQLite or outbox data (resolvePosLocalCatalogListError / resolvePosServerOrdersListError in app/point-of-sale/lib/pos-local-first-list-error.ts). The sale screen hides product/customer search dropdown errors under the same rule (shouldShowPosLocalCatalogFetchError).
POS work shift, hardware settings (terminal / printer / cash drawer), and related flows use the same probe for banners and load devices, lanes, and current open shift from SQLite when the API is down. Shift history still requires the server; open/close shift offline uses the existing outbox path.
Hardware settings (POS → terminal, printer, cash drawer)
On the point-of-sale hardware pages (settings-hardware-terminal-page, settings-hardware-printer-page, settings-hardware-cash-drawer-page), saving a device while local DB is enabled:
- Merges the row into TanStack Query and SQLite
catalog_cachekeysettings:devicesimmediately. - When the API probe is down, shows a local-only saved toast and does not require POS bridge activation before checkout or printing.
- On Electron:
- Terminals: card payments use
ecrDispatchwith LAN / WebSocket fields from cachedsettings:devices(resolveClientPaymentTerminalRouting/resolveVendorPaymentTerminalForLocalEcrwhen the in-memory row is incomplete offline). - Receipt printers:
printHtmluses the saved printer name (USB) or LAN row (TCP); bridge pairing is optional. - Cash drawers: kick-out uses the linked receipt printer via the same local print path.
- Terminals: card payments use
Place-order / outbox payloads include optional terminal_payment_context LAN fields (terminal_manufacturer, terminal_host, terminal_port, terminal_ws_url, terminal_app_id, local_device_key, …) so the server can persist routing on orders.terminal_payment_meta when the outbox syncs after reconnect. Printer and drawer rows are read from the same cached settings:devices snapshot at checkout time.
Electron checkout (cash/debt/wallet/check) writes to the outbox first and completes the success UI immediately.
Card while the API probe is down (desktop + local ECR + lane terminal configured):
- The order is written to
orders_outbox(local id). - The renderer runs
ecrDispatch→ pinpad SALE using that local id. - The ECR result is stored under
catalog_cache(pending_terminal_capture:{outboxId}) until sync. - When the server accepts the outbox row, the renderer posts
terminal-client-capturewith the realserver_order_id, then marks the row synced.
Terminal payment reference (digits only): card flows allocate a numeric merchant reference (Unix ms + 6 random digits) via @indochina/shared generatePosTerminalNumericReference / resolvePosTerminalPaymentReference. UUID outbox ids are not sent to pinpads as order refs; local ECR maps them to the numeric payment reference (resolvePosTerminalEcrOrderId). The pending-payment UI and customer display show the local display code (e.g. PBH-Q1-260526-0001) when allocated, otherwise the numeric ref — not the raw outbox UUID. Online place-order sets the same style ref on orders.transaction_reference and returns it in payment.transaction_ref for bridge / ECR dispatch.
Without local ECR or a configured terminal, card checkout still shows pos.cardPaymentRequiresOnline when offline. When online, card uses the normal HTTP place-order path and local ECR capture with the server order_id.
Cashiers with order / pos module access (not only settings) can load lane devices (terminals, receipt printers, cash drawer prefs) via GET /vendor/settings/devices — see canLoadVendorDevicesForPosCheckout in vendor-module-access.ts. Without a configured card terminal on the lane, checkout shows pos.cardPaymentNoTerminalConfigured.
The POS Items page (point-of-sale/items-page.tsx) loads the active catalog from grouped SQLite (itemsGrouped) and refreshes via fetchPosItemsGroupedLocalFirst when online. Vendor Desktop shows last sync time and a Sync catalog button (PosLocalSyncControl) that runs pullPosLocalCatalogFromServer.
The POS Orders page (point-of-sale/orders-page.tsx) shows a Sync status column on Vendor Desktop: server rows display Synced; pending/failed outbox rows appear at the top of page 1 with Not synced / Sync failed badges and a per-row Sync action that retries the outbox push. The toolbar adds Sync all (N) / Sync to server, which runs runPosLocalSyncCycle (push outbox, then pull catalog) and reports how many rows were attempted vs still pending.
Receipt print / preview before server sync
Receipt HTML is rendered from a single shared module (packages/shared/src/lib/order-receipt-html.renderer.ts), re-exported by the backend and used in vendor-web for offline paths.
On Vendor Desktop, tapping an outbox row (or Print receipt on that row) builds PosOrderReceiptData from the stored orders_outbox.payload_json (buildPosOrderReceiptDataFromOutboxPayload in apps/vendor-web/src/lib/pos-outbox-receipt-data.ts), renders HTML locally (renderPosOrderReceiptHtmlLocal), and opens the receipt preview modal with prefetched iframes — no GET …/receipt-html until the order exists on the server. Silent print uses the same HTML via attemptPrintReceiptInvoice when localReceiptData is set (Electron printHtml).
The printed Invoice line and barcode use the server EAN-13 (invoice_barcode, derived from orders.id + store_id when synced) — not the offline client submit id or #LOCAL-… placeholder. The human order code (PBH-… / local_order_code) stays on the sale reference line only.
Offline cancel/return actions on Electron enqueue a return outbox row and show a “will sync” toast.
Related code
Section titled “Related code”- Web flags:
apps/vendor-web/src/lib/vendor-pos-local-first.ts - Global settings bootstrap:
apps/vendor-web/src/components/point-of-sale/pos-local-settings-provider.tsx - Local-first services:
apps/vendor-web/src/services/pos-local-data.api.ts - Sync worker:
apps/vendor-web/src/hooks/use-pos-local-sync-worker.ts - Push/pull cycle:
apps/vendor-web/src/lib/pos-local-sync-cycle.ts - Desktop ECR:
apps/vendor-desktop/src/ecr-local-hub.ts - Backend capture:
PosService.applyTerminalPaymentFromVendorClientCapture - Outbox push:
apps/vendor-web/src/lib/pos-local-sync-push.ts - Display codes:
packages/shared/src/lib/pos-local-order-display-code.ts(web via Vite),apps/vendor-desktop/src/pos-local-order-display-code.ts(Electron main — mirrored copy, not@indochina/sharedimport),apps/vendor-web/src/lib/pos-local-order-display-code.ts - Receipt HTML (shared):
packages/shared/src/lib/order-receipt-html.renderer.ts, snapshot bridgepos-receipt-html-snapshot.ts; web offline:pos-outbox-receipt-data.ts,pos-receipt-html-local.ts,pos-offline-receipt-context.ts - Desktop allocation:
apps/vendor-desktop/src/pos-local-db.ts(allocateLocalOrderDisplayCode)