From c70ad6e08e313d7cc154238fd7891c7fc82ce8da Mon Sep 17 00:00:00 2001 From: mileo Date: Thu, 19 Mar 2026 12:23:23 -0300 Subject: [PATCH 01/14] [ADD] pos_pwa: PWA infrastructure for Point of Sale Add Service Worker with scope /pos (separate from Odoo's /odoo SW), web manifest, and offline fallback page. Extends the POS index template to register the SW and inject PWA meta tags. Cache strategies: - Static assets: stale-while-revalidate - Fonts/images: cache-first - POS HTML shell: network-first with 4s timeout - RPC calls: network-only (managed by PosData) --- pos_pwa/README.rst | 45 +++++ pos_pwa/__init__.py | 1 + pos_pwa/__manifest__.py | 18 ++ pos_pwa/controllers/__init__.py | 1 + pos_pwa/controllers/main.py | 80 +++++++++ pos_pwa/pyproject.toml | 3 + pos_pwa/static/description/icon.svg | 5 + pos_pwa/static/src/js/pos_sw.js | 246 ++++++++++++++++++++++++++++ pos_pwa/views/pos_pwa_templates.xml | 85 ++++++++++ 9 files changed, 484 insertions(+) create mode 100644 pos_pwa/README.rst create mode 100644 pos_pwa/__init__.py create mode 100644 pos_pwa/__manifest__.py create mode 100644 pos_pwa/controllers/__init__.py create mode 100644 pos_pwa/controllers/main.py create mode 100644 pos_pwa/pyproject.toml create mode 100644 pos_pwa/static/description/icon.svg create mode 100644 pos_pwa/static/src/js/pos_sw.js create mode 100644 pos_pwa/views/pos_pwa_templates.xml diff --git a/pos_pwa/README.rst b/pos_pwa/README.rst new file mode 100644 index 0000000000..2c3866011f --- /dev/null +++ b/pos_pwa/README.rst @@ -0,0 +1,45 @@ +======= +POS PWA +======= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:placeholder !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta + +|badge1| + +Progressive Web App infrastructure for Point of Sale. + +Adds Service Worker with scope ``/pos``, web manifest, and offline fallback page. +Extends the POS index template to register the SW and inject PWA meta tags. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. + +Credits +======= + +Authors +~~~~~~~ + +* KMEE + +Maintainers +~~~~~~~~~~~ + +This module is part of the `OCA/pos `_ project on GitHub. diff --git a/pos_pwa/__init__.py b/pos_pwa/__init__.py new file mode 100644 index 0000000000..e046e49fbe --- /dev/null +++ b/pos_pwa/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/pos_pwa/__manifest__.py b/pos_pwa/__manifest__.py new file mode 100644 index 0000000000..160d9f006f --- /dev/null +++ b/pos_pwa/__manifest__.py @@ -0,0 +1,18 @@ +{ + "name": "POS PWA", + "summary": "Progressive Web App infrastructure for Point of Sale", + "version": "18.0.1.0.0", + "development_status": "Beta", + "category": "Point of Sale", + "website": "https://github.com/OCA/pos", + "author": "KMEE, Odoo Community Association (OCA)", + "license": "LGPL-3", + "application": False, + "installable": True, + "depends": [ + "point_of_sale", + ], + "data": [ + "views/pos_pwa_templates.xml", + ], +} diff --git a/pos_pwa/controllers/__init__.py b/pos_pwa/controllers/__init__.py new file mode 100644 index 0000000000..12a7e529b6 --- /dev/null +++ b/pos_pwa/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/pos_pwa/controllers/main.py b/pos_pwa/controllers/main.py new file mode 100644 index 0000000000..a931e9d27c --- /dev/null +++ b/pos_pwa/controllers/main.py @@ -0,0 +1,80 @@ +import logging + +from odoo import http +from odoo.http import request +from odoo.tools import file_open + +_logger = logging.getLogger(__name__) + + +class PosPWAController(http.Controller): + @http.route( + "/pos/service-worker.js", + type="http", + auth="public", + methods=["GET"], + readonly=True, + ) + def pos_service_worker(self): + """Serve the POS Service Worker with correct scope headers.""" + with file_open("pos_pwa/static/src/js/pos_sw.js") as f: + body = f.read() + return request.make_response( + body, + headers=[ + ("Content-Type", "application/javascript"), + ("Service-Worker-Allowed", "/pos"), + ("Cache-Control", "no-cache"), + ], + ) + + @http.route( + "/pos/manifest.webmanifest", + type="http", + auth="public", + methods=["GET"], + readonly=True, + ) + def pos_manifest(self): + """Return the PWA manifest for the POS application.""" + manifest = self._get_pos_manifest() + return request.make_json_response( + manifest, + headers={"Content-Type": "application/manifest+json"}, + ) + + def _get_pos_manifest(self): + """Build the POS PWA manifest dictionary.""" + return { + "name": "Odoo POS", + "short_name": "POS", + "scope": "/pos", + "start_url": "/pos/ui", + "display": "standalone", + "background_color": "#222222", + "theme_color": "#714B67", + "prefer_related_applications": False, + "icons": [ + { + "src": "/point_of_sale/static/src/img/favicon.ico", + "sizes": "48x48", + "type": "image/x-icon", + }, + { + "src": "/pos_pwa/static/description/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + }, + ], + } + + @http.route( + "/pos/offline", + type="http", + auth="public", + methods=["GET"], + readonly=True, + ) + def pos_offline(self): + """Offline fallback page for the POS.""" + return request.render("pos_pwa.pos_offline_page") diff --git a/pos_pwa/pyproject.toml b/pos_pwa/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/pos_pwa/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/pos_pwa/static/description/icon.svg b/pos_pwa/static/description/icon.svg new file mode 100644 index 0000000000..cb5c56a764 --- /dev/null +++ b/pos_pwa/static/description/icon.svg @@ -0,0 +1,5 @@ + + + POS + PWA + diff --git a/pos_pwa/static/src/js/pos_sw.js b/pos_pwa/static/src/js/pos_sw.js new file mode 100644 index 0000000000..7ea1b955d5 --- /dev/null +++ b/pos_pwa/static/src/js/pos_sw.js @@ -0,0 +1,246 @@ +// @odoo-module ignore +// POS Service Worker - scope: /pos +/* global self, caches, fetch, setTimeout, clearTimeout, Response, URL */ +(function () { + "use strict"; + + var CACHE_NAME = "pos-pwa-cache-v1"; + var OFFLINE_URL = "/pos/offline"; + + // URLs to pre-cache during install + var PRECACHE_URLS = [OFFLINE_URL]; + + // Cache strategy configuration + var CACHE_STRATEGIES = { + // Static assets: cache-first with background update (stale-while-revalidate) + assets: { + match: function (url) { + return ( + url.pathname.startsWith("/web/assets/") || + url.pathname.startsWith("/web/static/") || + url.pathname.startsWith("/point_of_sale/static/") || + url.pathname.startsWith("/pos_pwa/static/") || + url.pathname.startsWith("/pos_offline/static/") + ); + }, + strategy: "stale-while-revalidate", + }, + // Fonts and images: cache-first, long duration + fonts: { + match: function (url) { + return /\.(woff2?|ttf|eot|otf)(\?.*)?$/.test(url.pathname); + }, + strategy: "cache-first", + }, + images: { + match: function (url) { + return /\.(png|jpg|jpeg|gif|ico|svg|webp)(\?.*)?$/.test(url.pathname); + }, + strategy: "cache-first", + }, + // POS HTML shell: network-first with timeout + shell: { + match: function (url) { + return url.pathname.startsWith("/pos/ui"); + }, + strategy: "network-first", + timeout: 4000, + }, + // RPC/API calls: network-only (PosData manages its own queue) + rpc: { + match: function (url) { + return ( + url.pathname.startsWith("/web/dataset/") || + url.pathname.startsWith("/web/action/") || + (url.pathname.startsWith("/web/") && url.pathname.includes("call")) + ); + }, + strategy: "network-only", + }, + }; + + // ===== Cache Strategies ===== + + function fetchWithTimeout(request, timeout) { + return new Promise(function (resolve, reject) { + var timer = setTimeout(function () { + reject(new Error("Timeout")); + }, timeout); + fetch(request) + .then(function (response) { + clearTimeout(timer); + resolve(response); + }) + .catch(function (err) { + clearTimeout(timer); + reject(err); + }); + }); + } + + function cacheFirst(request) { + return caches.match(request).then(function (cached) { + if (cached) { + return cached; + } + return fetch(request) + .then(function (response) { + if (response.ok) { + var cache = caches.open(CACHE_NAME); + cache.then(function (c) { + c.put(request, response.clone()); + }); + } + return response; + }) + .catch(function () { + return new Response("Network error", { + status: 503, + statusText: "Service Unavailable", + }); + }); + }); + } + + function staleWhileRevalidate(request) { + return caches.open(CACHE_NAME).then(function (cache) { + return cache.match(request).then(function (cached) { + var fetchPromise = fetch(request) + .then(function (response) { + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + }) + .catch(function () { + if (cached) { + return cached; + } + return new Response("Network error", { + status: 503, + statusText: "Service Unavailable", + }); + }); + + // Return cached version immediately if available, otherwise wait for network + return cached || fetchPromise; + }); + }); + } + + function networkFirst(request, timeout) { + return caches.open(CACHE_NAME).then(function (cache) { + return fetchWithTimeout(request, timeout) + .then(function (response) { + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + }) + .catch(function () { + return cache.match(request).then(function (cached) { + if (cached) { + return cached; + } + + // For navigation requests, show offline page + if (request.mode === "navigate") { + return cache + .match(OFFLINE_URL) + .then(function (offlinePage) { + if (offlinePage) { + return offlinePage; + } + return new Response("Network error", { + status: 503, + statusText: "Service Unavailable", + }); + }); + } + + return new Response("Network error", { + status: 503, + statusText: "Service Unavailable", + }); + }); + }); + }); + } + + // ===== Lifecycle Events ===== + + self.addEventListener("install", function (event) { + event.waitUntil( + caches.open(CACHE_NAME).then(function (cache) { + return cache.addAll(PRECACHE_URLS); + }) + ); + // Activate immediately without waiting for existing clients to close + self.skipWaiting(); + }); + + self.addEventListener("activate", function (event) { + event.waitUntil( + caches.keys().then(function (cacheNames) { + return Promise.all( + cacheNames + .filter(function (name) { + return ( + name.startsWith("pos-pwa-cache-") && name !== CACHE_NAME + ); + }) + .map(function (name) { + return caches.delete(name); + }) + ); + }) + ); + // Take control of all open clients immediately + self.clients.claim(); + }); + + // ===== Fetch Event ===== + + self.addEventListener("fetch", function (event) { + var url = new URL(event.request.url); + + // Only handle same-origin requests + if (url.origin !== self.location.origin) { + return; + } + + // Skip POST requests (RPC calls) - let them pass through + if (event.request.method !== "GET") { + return; + } + + // Find matching strategy + var strategies = Object.values(CACHE_STRATEGIES); + for (var i = 0; i < strategies.length; i++) { + var config = strategies[i]; + if (config.match(url)) { + switch (config.strategy) { + case "cache-first": + event.respondWith(cacheFirst(event.request)); + return; + case "stale-while-revalidate": + event.respondWith(staleWhileRevalidate(event.request)); + return; + case "network-first": + event.respondWith( + networkFirst(event.request, config.timeout || 4000) + ); + return; + case "network-only": + // Let the browser handle it normally + return; + } + } + } + + // For navigation requests to /pos/*, use network-first with offline fallback + if (event.request.mode === "navigate" && url.pathname.startsWith("/pos")) { + event.respondWith(networkFirst(event.request, 4000)); + } + }); +})(); diff --git a/pos_pwa/views/pos_pwa_templates.xml b/pos_pwa/views/pos_pwa_templates.xml new file mode 100644 index 0000000000..84af992e8f --- /dev/null +++ b/pos_pwa/views/pos_pwa_templates.xml @@ -0,0 +1,85 @@ + + + + + + + + From c0822f3855ff1c385b2fd573c12696320146f944 Mon Sep 17 00:00:00 2001 From: mileo Date: Thu, 19 Mar 2026 12:26:39 -0300 Subject: [PATCH 02/14] [ADD] pos_offline: full offline capability for Point of Sale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Depends on pos_pwa. Adds: - Startup offline: patches PosData.loadInitialData() to cache load_data response in IndexedDB and fall back to cache on ConnectionLostError - IndexedDB stores: overrides initIndexedDB() to add _pos_load_data_cache and _pending_orders stores (DB version bump to 2) - Robust sync: patches PosStore to persist pending orders to IndexedDB, restore on startup, and retry with exponential backoff (1s→60s max) - Offline payment: patches PaymentScreen to hide terminal-based methods when offline and skip server sync during finalization - Backend: idempotent sync_from_ui (UUID dedup), auto rescue session creation for orders arriving after session close - Config: offline_enabled field on pos.config --- pos_offline/README.rst | 45 +++++ pos_offline/__init__.py | 1 + pos_offline/__manifest__.py | 27 +++ pos_offline/models/__init__.py | 3 + pos_offline/models/pos_config.py | 13 ++ pos_offline/models/pos_order.py | 108 ++++++++++ pos_offline/models/pos_session.py | 43 ++++ pos_offline/pyproject.toml | 3 + pos_offline/static/description/icon.svg | 6 + .../src/js/data_service_options_patch.esm.js | 32 +++ .../static/src/js/data_service_patch.esm.js | 99 +++++++++ .../static/src/js/payment_screen_patch.esm.js | 69 +++++++ .../static/src/js/pos_store_patch.esm.js | 188 ++++++++++++++++++ pos_offline/static/src/xml/offline_banner.xml | 16 ++ pos_offline/views/pos_config_views.xml | 18 ++ 15 files changed, 671 insertions(+) create mode 100644 pos_offline/README.rst create mode 100644 pos_offline/__init__.py create mode 100644 pos_offline/__manifest__.py create mode 100644 pos_offline/models/__init__.py create mode 100644 pos_offline/models/pos_config.py create mode 100644 pos_offline/models/pos_order.py create mode 100644 pos_offline/models/pos_session.py create mode 100644 pos_offline/pyproject.toml create mode 100644 pos_offline/static/description/icon.svg create mode 100644 pos_offline/static/src/js/data_service_options_patch.esm.js create mode 100644 pos_offline/static/src/js/data_service_patch.esm.js create mode 100644 pos_offline/static/src/js/payment_screen_patch.esm.js create mode 100644 pos_offline/static/src/js/pos_store_patch.esm.js create mode 100644 pos_offline/static/src/xml/offline_banner.xml create mode 100644 pos_offline/views/pos_config_views.xml diff --git a/pos_offline/README.rst b/pos_offline/README.rst new file mode 100644 index 0000000000..692bb43bf4 --- /dev/null +++ b/pos_offline/README.rst @@ -0,0 +1,45 @@ +========== +POS Offline +========== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:placeholder !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta + +|badge1| + +Full offline capability for Point of Sale. + +Caches POS data in IndexedDB for offline startup, persists pending orders, +adds exponential-backoff retry sync, and provides an offline payment flow. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. + +Credits +======= + +Authors +~~~~~~~ + +* KMEE + +Maintainers +~~~~~~~~~~~ + +This module is part of the `OCA/pos `_ project on GitHub. diff --git a/pos_offline/__init__.py b/pos_offline/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/pos_offline/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/pos_offline/__manifest__.py b/pos_offline/__manifest__.py new file mode 100644 index 0000000000..27d52c44b1 --- /dev/null +++ b/pos_offline/__manifest__.py @@ -0,0 +1,27 @@ +{ + "name": "POS Offline", + "summary": "Full offline capability for Point of Sale", + "version": "18.0.1.0.0", + "development_status": "Beta", + "category": "Point of Sale", + "website": "https://github.com/OCA/pos", + "author": "KMEE, Odoo Community Association (OCA)", + "license": "LGPL-3", + "application": False, + "installable": True, + "depends": [ + "pos_pwa", + ], + "data": [ + "views/pos_config_views.xml", + ], + "assets": { + "point_of_sale._assets_pos": [ + "pos_offline/static/src/js/data_service_patch.esm.js", + "pos_offline/static/src/js/data_service_options_patch.esm.js", + "pos_offline/static/src/js/pos_store_patch.esm.js", + "pos_offline/static/src/js/payment_screen_patch.esm.js", + "pos_offline/static/src/xml/offline_banner.xml", + ], + }, +} diff --git a/pos_offline/models/__init__.py b/pos_offline/models/__init__.py new file mode 100644 index 0000000000..bc8a32b6c3 --- /dev/null +++ b/pos_offline/models/__init__.py @@ -0,0 +1,3 @@ +from . import pos_config +from . import pos_session +from . import pos_order diff --git a/pos_offline/models/pos_config.py b/pos_offline/models/pos_config.py new file mode 100644 index 0000000000..01c1c5086f --- /dev/null +++ b/pos_offline/models/pos_config.py @@ -0,0 +1,13 @@ +from odoo import fields, models + + +class PosConfig(models.Model): + _inherit = "pos.config" + + offline_enabled = fields.Boolean( + string="Offline Mode", + default=True, + help="Enable offline mode for this POS. When enabled, the POS will " + "cache data locally and continue operating when the network is " + "unavailable.", + ) diff --git a/pos_offline/models/pos_order.py b/pos_offline/models/pos_order.py new file mode 100644 index 0000000000..b49c6bb1b5 --- /dev/null +++ b/pos_offline/models/pos_order.py @@ -0,0 +1,108 @@ +import logging + +from odoo import _, api, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class PosOrder(models.Model): + _inherit = "pos.order" + + def _get_valid_session(self, order): + """Override to create rescue session automatically for offline orders. + + When a POS operates offline, orders may arrive after the original + session has been closed. Instead of raising an error, we create + a rescue session automatically. + """ + PosSession = self.env["pos.session"] + closed_session = PosSession.browse(order["session_id"]) + + if not closed_session.exists(): + _logger.error( + "Session ID %s does not exist for order %s", + order["session_id"], + order.get("name", "Unknown"), + ) + raise UserError( + _( + "Cannot process offline order %(order)s: " + "the original session (ID %(session)s) no longer exists.", + order=order.get("name", "Unknown"), + session=order["session_id"], + ) + ) + + if closed_session.state not in ("closed", "closing_control"): + # Session is still open, delegate to core logic + return super()._get_valid_session(order) + + _logger.warning( + "Session %s (ID: %s) was closed but received offline order %s " + "(total: %s)", + closed_session.name, + closed_session.id, + order.get("name", "Unknown"), + order.get("amount_total", 0), + ) + + # Try to find an existing open session for this config + open_session = PosSession.search( + [ + ("state", "not in", ("closed", "closing_control")), + ("config_id", "=", closed_session.config_id.id), + ], + limit=1, + ) + + if open_session: + _logger.warning( + "Using open session %s for saving offline order %s", + open_session.name, + order.get("name", "Unknown"), + ) + return open_session + + # Create rescue session automatically + rescue_session = PosSession._create_rescue_session(closed_session) + return rescue_session + + @api.model + def sync_from_ui(self, orders): + """Override to handle idempotent sync for offline orders. + + When orders are synced from offline, the same order may be sent + multiple times. We detect duplicates by UUID and skip them. + """ + filtered_orders = [] + for order in orders: + order_uuid = order.get("uuid") + if order_uuid: + existing = self.search( + [("uuid", "=", order_uuid), ("state", "!=", "draft")], + limit=1, + ) + if existing: + _logger.info( + "Skipping duplicate offline order %s (UUID: %s, " + "existing ID: %s)", + order.get("name", "Unknown"), + order_uuid, + existing.id, + ) + continue + filtered_orders.append(order) + + if not filtered_orders: + # Return empty result in the expected format + return { + "pos.order": [], + "pos.session": [], + "pos.payment": [], + "pos.order.line": [], + "pos.pack.operation.lot": [], + "product.attribute.custom.value": [], + } + + return super().sync_from_ui(filtered_orders) diff --git a/pos_offline/models/pos_session.py b/pos_offline/models/pos_session.py new file mode 100644 index 0000000000..b6082f19cc --- /dev/null +++ b/pos_offline/models/pos_session.py @@ -0,0 +1,43 @@ +import logging + +from odoo import models + +_logger = logging.getLogger(__name__) + + +class PosSession(models.Model): + _inherit = "pos.session" + + def _get_rescue_session_values(self, closed_session): + """Return values for creating a rescue session from a closed one. + + This ensures the rescue session inherits proper starting balances + and sequence numbers from the original session. + """ + return { + "config_id": closed_session.config_id.id, + "user_id": closed_session.user_id.id, + "cash_register_balance_start": closed_session.cash_register_balance_end, + "sequence_number": closed_session.sequence_number, + } + + def _create_rescue_session(self, closed_session): + """Create a rescue session when orders arrive for a closed session. + + This is called during sync_from_ui when a session has been closed + but pending offline orders still reference it. + """ + values = self._get_rescue_session_values(closed_session) + rescue_session = self.sudo().create(values) + + # Auto-open the rescue session + rescue_session.action_pos_session_open() + + _logger.info( + "Created rescue session %s (ID: %s) for closed session %s (ID: %s)", + rescue_session.name, + rescue_session.id, + closed_session.name, + closed_session.id, + ) + return rescue_session diff --git a/pos_offline/pyproject.toml b/pos_offline/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/pos_offline/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/pos_offline/static/description/icon.svg b/pos_offline/static/description/icon.svg new file mode 100644 index 0000000000..00909cb269 --- /dev/null +++ b/pos_offline/static/description/icon.svg @@ -0,0 +1,6 @@ + + + POS + OFFLINE + + diff --git a/pos_offline/static/src/js/data_service_options_patch.esm.js b/pos_offline/static/src/js/data_service_options_patch.esm.js new file mode 100644 index 0000000000..1fd2c32e8a --- /dev/null +++ b/pos_offline/static/src/js/data_service_options_patch.esm.js @@ -0,0 +1,32 @@ +/** @odoo-module */ + +import IndexedDB from "@point_of_sale/app/models/utils/indexed_db"; +import {PosData} from "@point_of_sale/app/models/data_service"; +import {patch} from "@web/core/utils/patch"; + +/** + * Patch PosData to add custom IndexedDB stores for offline support. + * + * We override initIndexedDB() rather than databaseTable because our + * stores (_pos_load_data_cache, _pending_orders) are not ORM models + * and should not participate in the syncDataWithIndexedDB cycle. + */ + +// Bump the IndexedDB version to trigger onupgradeneeded and create new stores +const OFFLINE_DB_VERSION = 2; + +patch(PosData.prototype, { + initIndexedDB() { + // Get the standard model stores from databaseTable + const models = Object.entries(this.opts.databaseTable).map(([name, data]) => [ + data.key, + name, + ]); + + // Add custom stores for offline support + models.push(["config_id", "_pos_load_data_cache"]); + models.push(["uuid", "_pending_orders"]); + + this.indexedDB = new IndexedDB(this.databaseName, OFFLINE_DB_VERSION, models); + }, +}); diff --git a/pos_offline/static/src/js/data_service_patch.esm.js b/pos_offline/static/src/js/data_service_patch.esm.js new file mode 100644 index 0000000000..f9423d2b46 --- /dev/null +++ b/pos_offline/static/src/js/data_service_patch.esm.js @@ -0,0 +1,99 @@ +/** @odoo-module */ +/* global navigator, console, window */ + +import {PosData} from "@point_of_sale/app/models/data_service"; +import {_t} from "@web/core/l10n/translation"; +import {patch} from "@web/core/utils/patch"; + +const LOAD_DATA_CACHE_STORE = "_pos_load_data_cache"; + +patch(PosData.prototype, { + /** + * Override loadInitialData to: + * 1. Cache the response in IndexedDB on success + * 2. Fall back to cached data when offline + * + * Calls super.loadInitialData() to preserve compatibility with other patches. + * The super returns undefined on error (after showing alert). We intercept + * ConnectionLostError before it reaches super's catch by wrapping the call. + */ + async loadInitialData() { + // First, try super (which calls orm.call and shows alert on error) + const response = await super.loadInitialData(); + + if (response) { + // Success: cache for offline use + await this._cacheLoadData(response); + return response; + } + + // Super returned undefined — either an error occurred or data was empty. + // Check if we're offline and can use cache. + if (!navigator.onLine || this.network.offline) { + console.warn( + "[POS Offline] loadInitialData returned empty, trying cache..." + ); + const cached = await this._getCachedLoadData(); + if (cached) { + this.network.offline = true; + console.info("[POS Offline] Loaded data from IndexedDB cache"); + return cached.data; + } + window.alert( + _t( + "You are offline and no cached data is available. " + + "Please open the POS online at least once to enable offline mode." + ) + ); + } + + return response; + }, + + /** + * Cache the load_data response in IndexedDB. + * @param {Object} response - The full load_data response + */ + async _cacheLoadData(response) { + try { + const cacheEntry = { + config_id: odoo.pos_config_id, + data: response, + timestamp: new Date().toISOString(), + session_id: odoo.pos_session_id, + }; + await this.indexedDB.create(LOAD_DATA_CACHE_STORE, [cacheEntry]); + console.info( + "[POS Offline] Cached load_data for config", + odoo.pos_config_id + ); + } catch (error) { + console.warn("[POS Offline] Failed to cache load_data:", error); + } + }, + + /** + * Retrieve cached load_data from IndexedDB. + * @returns {Object|null} The cached entry or null + */ + async _getCachedLoadData() { + try { + const data = await this.indexedDB.readAll([LOAD_DATA_CACHE_STORE]); + if (data && data[LOAD_DATA_CACHE_STORE]) { + const entries = data[LOAD_DATA_CACHE_STORE]; + const entry = entries.find((e) => e.config_id === odoo.pos_config_id); + if (entry) { + console.info( + "[POS Offline] Found cached data from", + entry.timestamp + ); + return entry; + } + } + return null; + } catch (error) { + console.warn("[POS Offline] Failed to read cached load_data:", error); + return null; + } + }, +}); diff --git a/pos_offline/static/src/js/payment_screen_patch.esm.js b/pos_offline/static/src/js/payment_screen_patch.esm.js new file mode 100644 index 0000000000..fbc0095261 --- /dev/null +++ b/pos_offline/static/src/js/payment_screen_patch.esm.js @@ -0,0 +1,69 @@ +/** @odoo-module */ + +import {PaymentScreen} from "@point_of_sale/app/screens/payment_screen/payment_screen"; +import {_t} from "@web/core/l10n/translation"; +import {patch} from "@web/core/utils/patch"; + +patch(PaymentScreen.prototype, { + setup() { + super.setup(); + // Store all payment methods before any filtering + this._allPaymentMethods = [...this.payment_methods_from_config]; + }, + + /** + * Get payment methods filtered by offline status. + * When offline, hide methods that require a payment terminal. + */ + getAvailablePaymentMethods() { + if (this.pos.data.network.offline) { + return this._allPaymentMethods.filter((pm) => !pm.use_payment_terminal); + } + return this._allPaymentMethods; + }, + + onMounted() { + // Filter payment methods based on current online/offline state + this.payment_methods_from_config = this.getAvailablePaymentMethods(); + super.onMounted(); + }, + + /** + * Override _finalizeValidation to handle offline scenarios: + * - Skip invoice download when offline + * - Mark order for invoicing on sync + */ + async _finalizeValidation() { + const isOffline = this.pos.data.network.offline; + + if (!isOffline) { + return super._finalizeValidation(); + } + + // === Offline finalization flow === + if (this.currentOrder.is_paid_with_cash() || this.currentOrder.get_change()) { + this.hardwareProxy.openCashbox(); + } + + const {serializeDateTime} = await import("@web/core/l10n/dates"); + this.currentOrder.date_order = serializeDateTime(luxon.DateTime.now()); + + for (const line of this.paymentLines) { + if (line.amount === 0) { + this.currentOrder.remove_paymentline(line); + } + } + + this.pos.addPendingOrder([this.currentOrder.id]); + this.currentOrder.state = "paid"; + + // Don't try to sync - go directly to receipt + // Note: invoicing is handled by the normal sync flow when back online. + this.notification.add( + _t("Order saved offline. It will be synced when connection is restored."), + {type: "warning"} + ); + + await this.afterOrderValidation(false); + }, +}); diff --git a/pos_offline/static/src/js/pos_store_patch.esm.js b/pos_offline/static/src/js/pos_store_patch.esm.js new file mode 100644 index 0000000000..5a031354a1 --- /dev/null +++ b/pos_offline/static/src/js/pos_store_patch.esm.js @@ -0,0 +1,188 @@ +/** @odoo-module */ +/* global console, navigator, setTimeout, setInterval, clearInterval */ + +import {ConnectionLostError} from "@web/core/network/rpc"; +import {PosStore} from "@point_of_sale/app/store/pos_store"; +import {patch} from "@web/core/utils/patch"; + +const PENDING_ORDERS_STORE = "_pending_orders"; + +// Retry configuration +const RETRY_INITIAL_DELAY = 1000; +const RETRY_MAX_DELAY = 60000; +const RETRY_CHECK_INTERVAL = 30000; + +patch(PosStore.prototype, { + async setup(env, services) { + await super.setup(env, services); + + // Restore pending orders from IndexedDB on startup + await this._restorePendingOrdersFromDB(); + + // Setup periodic sync check as safety net + this._retryDelay = RETRY_INITIAL_DELAY; + this._retryTimer = null; + this._syncCheckIntervalId = null; + this._setupPeriodicSyncCheck(); + }, + + // ===== Pending Orders Persistence ===== + + addPendingOrder(orderIds, remove = false) { + const result = super.addPendingOrder(orderIds, remove); + this._persistPendingOrdersToDB(); + return result; + }, + + removePendingOrder(order) { + const result = super.removePendingOrder(order); + this._persistPendingOrdersToDB(); + return result; + }, + + clearPendingOrder() { + super.clearPendingOrder(); + this._persistPendingOrdersToDB(); + }, + + /** + * Persist the current pending order Sets to IndexedDB. + */ + async _persistPendingOrdersToDB() { + try { + const entry = { + uuid: "pending_orders_state", + create: [...this.pendingOrder.create], + write: [...this.pendingOrder.write], + delete_ids: [...this.pendingOrder.delete], + timestamp: new Date().toISOString(), + }; + await this.data.indexedDB.create(PENDING_ORDERS_STORE, [entry]); + } catch (error) { + console.warn("[POS Offline] Failed to persist pending orders:", error); + } + }, + + /** + * Restore pending order Sets from IndexedDB on startup. + */ + async _restorePendingOrdersFromDB() { + try { + const data = await this.data.indexedDB.readAll([PENDING_ORDERS_STORE]); + if (data && data[PENDING_ORDERS_STORE]) { + const entries = data[PENDING_ORDERS_STORE]; + const entry = entries.find((e) => e.uuid === "pending_orders_state"); + if (entry) { + // Merge with any existing pending orders (don't overwrite) + for (const id of entry.create || []) { + this.pendingOrder.create.add(id); + } + for (const id of entry.write || []) { + this.pendingOrder.write.add(id); + } + for (const id of entry.delete_ids || []) { + this.pendingOrder.delete.add(id); + } + const totalPending = + this.pendingOrder.create.size + + this.pendingOrder.write.size + + this.pendingOrder.delete.size; + if (totalPending > 0) { + console.info( + `[POS Offline] Restored ${totalPending} pending order operations from IndexedDB` + ); + } + } + } + } catch (error) { + console.warn("[POS Offline] Failed to restore pending orders:", error); + } + }, + + // ===== Sync with Retry ===== + + async syncAllOrders(options = {}) { + try { + const result = await super.syncAllOrders(options); + // Reset retry delay on success + this._retryDelay = RETRY_INITIAL_DELAY; + return result; + } catch (error) { + if (error instanceof ConnectionLostError) { + this._scheduleRetrySync(); + } + throw error; + } + }, + + /** + * Schedule a retry sync with exponential backoff. + * Delay progression: 1s → 2s → 4s → 8s → 16s → 32s → 60s (max) + */ + _scheduleRetrySync() { + if (this._retryTimer) { + return; // Already scheduled + } + + const currentDelay = this._retryDelay; + // Bump delay for next call BEFORE scheduling, so recursive calls use the new value + this._retryDelay = Math.min(this._retryDelay * 2, RETRY_MAX_DELAY); + + console.info(`[POS Offline] Scheduling sync retry in ${currentDelay / 1000}s`); + + this._retryTimer = setTimeout(async () => { + this._retryTimer = null; + + const hasPending = + this.pendingOrder.create.size > 0 || this.pendingOrder.write.size > 0; + + if (hasPending && navigator.onLine) { + try { + await this.syncAllOrders(); + } catch (e) { + // SyncAllOrders catch will call _scheduleRetrySync again + console.warn("[POS Offline] Retry sync failed:", e.message); + } + } else if (hasPending) { + // Still offline, schedule another retry + this._scheduleRetrySync(); + } + }, currentDelay); + }, + + /** + * Setup periodic check for unsync'd orders as a safety net. + * Stores the interval ID so it can be cleaned up if needed. + */ + _setupPeriodicSyncCheck() { + if (this._syncCheckIntervalId) { + clearInterval(this._syncCheckIntervalId); + } + this._syncCheckIntervalId = setInterval(async () => { + const hasPending = + this.pendingOrder.create.size > 0 || this.pendingOrder.write.size > 0; + + if (hasPending && navigator.onLine && !this.data.network.offline) { + console.info("[POS Offline] Periodic check: syncing pending orders..."); + try { + await this.syncAllOrders(); + } catch { + // Silently ignore, retry will handle it + } + } + }, RETRY_CHECK_INTERVAL); + }, + + // ===== Session Close Guard ===== + + /** + * Check if there are pending orders that need to be synced before closing. + */ + hasPendingOrdersToSync() { + return ( + this.pendingOrder.create.size > 0 || + this.pendingOrder.write.size > 0 || + this.pendingOrder.delete.size > 0 + ); + }, +}); diff --git a/pos_offline/static/src/xml/offline_banner.xml b/pos_offline/static/src/xml/offline_banner.xml new file mode 100644 index 0000000000..267dc8b507 --- /dev/null +++ b/pos_offline/static/src/xml/offline_banner.xml @@ -0,0 +1,16 @@ + + + + + +
+ + Offline Mode - Orders will sync when connection is restored +
+
+ +
diff --git a/pos_offline/views/pos_config_views.xml b/pos_offline/views/pos_config_views.xml new file mode 100644 index 0000000000..5ff992019b --- /dev/null +++ b/pos_offline/views/pos_config_views.xml @@ -0,0 +1,18 @@ + + + + pos.config.form.offline + pos.config + + + + + + + + + + From 863f68d86561fcdd86531544dbdef833c8f680b2 Mon Sep 17 00:00:00 2001 From: mileo Date: Fri, 20 Mar 2026 10:59:24 -0300 Subject: [PATCH 03/14] [FIX] pos_pwa, pos_offline: fix manifest 404 and IndexedDB crash pos_pwa: - Fix manifest.webmanifest returning 404 by using make_response with json.dumps instead of make_json_response - Replace deprecated apple-mobile-web-app-capable with mobile-web-app-capable meta tag pos_offline: - Fix TypeError "Cannot read properties of undefined (reading 'id')" on POS load. The custom IndexedDB stores (_pos_load_data_cache, _pending_orders) were being included in readAll() results and passed to missingRecursive/loadData which tried to process them as ORM models. Now loadIndexedDBData is overridden to strip custom stores before processing. --- .../src/js/data_service_options_patch.esm.js | 57 +++++++++++++++++++ pos_pwa/controllers/main.py | 11 +++- pos_pwa/views/pos_pwa_templates.xml | 2 +- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/pos_offline/static/src/js/data_service_options_patch.esm.js b/pos_offline/static/src/js/data_service_options_patch.esm.js index 1fd2c32e8a..487c11eb43 100644 --- a/pos_offline/static/src/js/data_service_options_patch.esm.js +++ b/pos_offline/static/src/js/data_service_options_patch.esm.js @@ -10,11 +10,18 @@ import {patch} from "@web/core/utils/patch"; * We override initIndexedDB() rather than databaseTable because our * stores (_pos_load_data_cache, _pending_orders) are not ORM models * and should not participate in the syncDataWithIndexedDB cycle. + * + * We also override loadIndexedDBData() to strip custom stores from + * the data before it reaches missingRecursive/loadData — otherwise + * those functions try to process our stores as ORM models and crash. */ // Bump the IndexedDB version to trigger onupgradeneeded and create new stores const OFFLINE_DB_VERSION = 2; +// Store names that are NOT ORM models — must be stripped from readAll() results +const CUSTOM_STORES = new Set(["_pos_load_data_cache", "_pending_orders"]); + patch(PosData.prototype, { initIndexedDB() { // Get the standard model stores from databaseTable @@ -29,4 +36,54 @@ patch(PosData.prototype, { this.indexedDB = new IndexedDB(this.databaseName, OFFLINE_DB_VERSION, models); }, + + async loadIndexedDBData() { + const data = await this.indexedDB.readAll(); + + if (!data) { + return; + } + + // Strip custom stores before processing — they are not ORM models + // and would crash missingRecursive/loadData + for (const key of CUSTOM_STORES) { + delete data[key]; + } + + // Now delegate to the original logic with clean data + // We must inline the rest of super.loadIndexedDBData() because + // it already called readAll() and we can't re-call super (it would + // readAll again without our cleanup). + const newData = {}; + for (const model of Object.keys(this.opts.databaseTable)) { + const rawRec = data[model]; + if (rawRec) { + newData[model] = rawRec.filter((r) => !this.models[model].get(r.id)); + } + } + + if (data["product.product"]) { + data["product.product"] = data["product.product"].filter( + (p) => !this.models["product.product"].get(p.id) + ); + } + + const preLoadData = await this.preLoadData(data); + const missing = await this.missingRecursive(preLoadData); + const results = this.models.loadData(missing, [], true, true); + for (const [modelName, records] of Object.entries(results)) { + for (const record of records) { + if (record.raw.JSONuiState) { + const loadedRecords = this.models[modelName].find( + (r) => r.uuid === record.uuid + ); + if (loadedRecords) { + loadedRecords.setupState(JSON.parse(record.raw.JSONuiState)); + } + } + } + } + + return results; + }, }); diff --git a/pos_pwa/controllers/main.py b/pos_pwa/controllers/main.py index a931e9d27c..fc91f08082 100644 --- a/pos_pwa/controllers/main.py +++ b/pos_pwa/controllers/main.py @@ -37,10 +37,15 @@ def pos_service_worker(self): ) def pos_manifest(self): """Return the PWA manifest for the POS application.""" + import json + manifest = self._get_pos_manifest() - return request.make_json_response( - manifest, - headers={"Content-Type": "application/manifest+json"}, + body = json.dumps(manifest) + return request.make_response( + body, + headers=[ + ("Content-Type", "application/manifest+json"), + ], ) def _get_pos_manifest(self): diff --git a/pos_pwa/views/pos_pwa_templates.xml b/pos_pwa/views/pos_pwa_templates.xml index 84af992e8f..60ef969696 100644 --- a/pos_pwa/views/pos_pwa_templates.xml +++ b/pos_pwa/views/pos_pwa_templates.xml @@ -8,7 +8,7 @@ > - + Date: Fri, 20 Mar 2026 11:46:03 -0300 Subject: [PATCH 04/14] [FIX] pos_pwa: fix manifest 404 with alternative route The /pos/manifest.webmanifest route returns 404 despite the controller being loaded (service-worker.js works). This may be caused by Odoo's routing treating .webmanifest as a static file extension. Add alternative route /pos/pwa_manifest without file extension and use it in the manifest link tag. Keep the original route as fallback. --- pos_pwa/controllers/main.py | 2 +- pos_pwa/views/pos_pwa_templates.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pos_pwa/controllers/main.py b/pos_pwa/controllers/main.py index fc91f08082..bd9e643120 100644 --- a/pos_pwa/controllers/main.py +++ b/pos_pwa/controllers/main.py @@ -29,7 +29,7 @@ def pos_service_worker(self): ) @http.route( - "/pos/manifest.webmanifest", + ["/pos/manifest.webmanifest", "/pos/pwa_manifest"], type="http", auth="public", methods=["GET"], diff --git a/pos_pwa/views/pos_pwa_templates.xml b/pos_pwa/views/pos_pwa_templates.xml index 60ef969696..2ff20de05c 100644 --- a/pos_pwa/views/pos_pwa_templates.xml +++ b/pos_pwa/views/pos_pwa_templates.xml @@ -7,7 +7,7 @@ name="POS PWA Header" > - + Date: Sun, 22 Mar 2026 18:59:05 -0300 Subject: [PATCH 05/14] [FIX] pos_pwa, pos_offline: offline startup fixes pos_pwa: - Serve offline page as static HTML file instead of QWeb template (server may be unreachable when SW needs to serve it) - Add /pos/pwa_manifest route alongside /pos/manifest.webmanifest - Remove QWeb pos_offline_page template (replaced by static HTML) pos_offline: - Rewrite loadInitialData to call orm.call directly instead of super, intercepting network errors BEFORE the core's blocking window.alert. On ConnectionLostError or network failure, falls back to IndexedDB cache immediately. - Add _isNetworkError helper for non-ConnectionLostError failures (ERR_INTERNET_DISCONNECTED, Failed to fetch, etc.) - Patch BarcodeParser.fetchNomenclature to catch network errors and return empty nomenclature instead of blocking POS startup --- pos_offline/__manifest__.py | 1 + .../static/src/js/barcode_reader_patch.esm.js | 30 +++++++ .../static/src/js/data_service_patch.esm.js | 87 ++++++++++++------- pos_pwa/controllers/main.py | 17 +++- pos_pwa/static/src/html/offline.html | 58 +++++++++++++ pos_pwa/views/pos_pwa_templates.xml | 51 ----------- 6 files changed, 158 insertions(+), 86 deletions(-) create mode 100644 pos_offline/static/src/js/barcode_reader_patch.esm.js create mode 100644 pos_pwa/static/src/html/offline.html diff --git a/pos_offline/__manifest__.py b/pos_offline/__manifest__.py index 27d52c44b1..0320748eae 100644 --- a/pos_offline/__manifest__.py +++ b/pos_offline/__manifest__.py @@ -21,6 +21,7 @@ "pos_offline/static/src/js/data_service_options_patch.esm.js", "pos_offline/static/src/js/pos_store_patch.esm.js", "pos_offline/static/src/js/payment_screen_patch.esm.js", + "pos_offline/static/src/js/barcode_reader_patch.esm.js", "pos_offline/static/src/xml/offline_banner.xml", ], }, diff --git a/pos_offline/static/src/js/barcode_reader_patch.esm.js b/pos_offline/static/src/js/barcode_reader_patch.esm.js new file mode 100644 index 0000000000..14b089959f --- /dev/null +++ b/pos_offline/static/src/js/barcode_reader_patch.esm.js @@ -0,0 +1,30 @@ +/** @odoo-module */ +/* global console */ + +import {BarcodeParser} from "@barcodes/js/barcode_parser"; +import {patch} from "@web/core/utils/patch"; + +/** + * Patch BarcodeParser.fetchNomenclature to gracefully handle network errors. + * Without this, the barcode_reader_service blocks POS startup when offline + * because fetchNomenclature does orm.read which throws ConnectionLostError. + */ +patch(BarcodeParser, { + async fetchNomenclature(orm, id) { + try { + return await super.fetchNomenclature(orm, id); + } catch (error) { + console.warn( + "[POS Offline] Failed to fetch barcode nomenclature, barcode scanning disabled:", + error.message + ); + // Return a minimal nomenclature so the service can still start + return { + id: id, + name: "Offline (cached)", + upc_ean_conv: "none", + rules: [], + }; + } + }, +}); diff --git a/pos_offline/static/src/js/data_service_patch.esm.js b/pos_offline/static/src/js/data_service_patch.esm.js index f9423d2b46..714dd058ec 100644 --- a/pos_offline/static/src/js/data_service_patch.esm.js +++ b/pos_offline/static/src/js/data_service_patch.esm.js @@ -1,6 +1,7 @@ /** @odoo-module */ /* global navigator, console, window */ +import {ConnectionLostError} from "@web/core/network/rpc"; import {PosData} from "@point_of_sale/app/models/data_service"; import {_t} from "@web/core/l10n/translation"; import {patch} from "@web/core/utils/patch"; @@ -10,44 +11,49 @@ const LOAD_DATA_CACHE_STORE = "_pos_load_data_cache"; patch(PosData.prototype, { /** * Override loadInitialData to: - * 1. Cache the response in IndexedDB on success - * 2. Fall back to cached data when offline + * 1. On success: cache the response in IndexedDB for offline use + * 2. On network error: fall back to cached data from IndexedDB * - * Calls super.loadInitialData() to preserve compatibility with other patches. - * The super returns undefined on error (after showing alert). We intercept - * ConnectionLostError before it reaches super's catch by wrapping the call. + * We call orm.call directly instead of super to intercept network + * errors BEFORE the core's catch block shows a blocking alert. */ async loadInitialData() { - // First, try super (which calls orm.call and shows alert on error) - const response = await super.loadInitialData(); - - if (response) { - // Success: cache for offline use - await this._cacheLoadData(response); + try { + const response = await this.orm.call("pos.session", "load_data", [ + odoo.pos_session_id, + PosData.modelToLoad, + ]); + if (response) { + this._cacheLoadData(response); + } return response; - } - - // Super returned undefined — either an error occurred or data was empty. - // Check if we're offline and can use cache. - if (!navigator.onLine || this.network.offline) { - console.warn( - "[POS Offline] loadInitialData returned empty, trying cache..." - ); - const cached = await this._getCachedLoadData(); - if (cached) { - this.network.offline = true; - console.info("[POS Offline] Loaded data from IndexedDB cache"); - return cached.data; + } catch (error) { + // Check if this is a network error (offline scenario) + if ( + error instanceof ConnectionLostError || + !navigator.onLine || + this._isNetworkError(error) + ) { + console.warn( + "[POS Offline] Network error during loadInitialData, trying cache..." + ); + const cached = await this._getCachedLoadData(); + if (cached) { + this.network.offline = true; + console.info("[POS Offline] Loaded data from IndexedDB cache"); + return cached.data; + } + window.alert( + _t( + "You are offline and no cached data is available. " + + "Please open the POS online at least once to enable offline mode." + ) + ); + return undefined; } - window.alert( - _t( - "You are offline and no cached data is available. " + - "Please open the POS online at least once to enable offline mode." - ) - ); + // Non-network error: fall back to super behavior (shows alert) + return super.loadInitialData(); } - - return response; }, /** @@ -96,4 +102,21 @@ patch(PosData.prototype, { return null; } }, + + /** + * Check if an error is a network-related error beyond ConnectionLostError. + */ + _isNetworkError(error) { + if (!error) { + return false; + } + var msg = error.message || ""; + return ( + msg.includes("Failed to fetch") || + msg.includes("Load failed") || + msg.includes("NetworkError") || + msg.includes("Network request failed") || + msg.includes("ERR_INTERNET_DISCONNECTED") + ); + }, }); diff --git a/pos_pwa/controllers/main.py b/pos_pwa/controllers/main.py index bd9e643120..cb82774bb0 100644 --- a/pos_pwa/controllers/main.py +++ b/pos_pwa/controllers/main.py @@ -29,7 +29,7 @@ def pos_service_worker(self): ) @http.route( - ["/pos/manifest.webmanifest", "/pos/pwa_manifest"], + ["/pos/pwa_manifest", "/pos/manifest.webmanifest"], type="http", auth="public", methods=["GET"], @@ -81,5 +81,16 @@ def _get_pos_manifest(self): readonly=True, ) def pos_offline(self): - """Offline fallback page for the POS.""" - return request.render("pos_pwa.pos_offline_page") + """Offline fallback page - served as static HTML. + + This page is pre-cached by the Service Worker during install. + It must not depend on QWeb rendering (server may be unreachable). + """ + with file_open("pos_pwa/static/src/html/offline.html") as f: + body = f.read() + return request.make_response( + body, + headers=[ + ("Content-Type", "text/html; charset=utf-8"), + ], + ) diff --git a/pos_pwa/static/src/html/offline.html b/pos_pwa/static/src/html/offline.html new file mode 100644 index 0000000000..41de495205 --- /dev/null +++ b/pos_pwa/static/src/html/offline.html @@ -0,0 +1,58 @@ + + + + + + POS - Offline + + + +
+

POS Offline

+

+ You are currently offline. Please check your internet connection and try again. +

+

+ If you have previously opened the POS, it should load from cache automatically. +

+ +
+ + diff --git a/pos_pwa/views/pos_pwa_templates.xml b/pos_pwa/views/pos_pwa_templates.xml index 2ff20de05c..148f2fa844 100644 --- a/pos_pwa/views/pos_pwa_templates.xml +++ b/pos_pwa/views/pos_pwa_templates.xml @@ -31,55 +31,4 @@ if ("serviceWorker" in navigator) {
- - - From 107e1e3ec246ec2ff8c0c42dbaf18bc3400a6509 Mon Sep 17 00:00:00 2001 From: mileo Date: Sun, 22 Mar 2026 19:14:02 -0300 Subject: [PATCH 06/14] [FIX] pos_offline: complete offline startup (IndexedDB wait + afterProcessServerData) - data_service_patch: call orm.call directly instead of super to intercept network errors before the core's blocking window.alert. Add _waitForIndexedDB to handle race condition where IndexedDB is not yet ready when loadInitialData runs offline. Add _isNetworkError for ERR_INTERNET_DISCONNECTED etc. - pos_store_patch: override afterProcessServerData to skip readDataFromServer when offline, go directly to markReady/showScreen. - Add barcode_reader_patch to manifest assets list. --- .../static/src/js/data_service_patch.esm.js | 44 +++++++++++-------- .../static/src/js/pos_store_patch.esm.js | 21 +++++++++ 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/pos_offline/static/src/js/data_service_patch.esm.js b/pos_offline/static/src/js/data_service_patch.esm.js index 714dd058ec..e66e55525c 100644 --- a/pos_offline/static/src/js/data_service_patch.esm.js +++ b/pos_offline/static/src/js/data_service_patch.esm.js @@ -1,5 +1,5 @@ /** @odoo-module */ -/* global navigator, console, window */ +/* global navigator, console, window, setTimeout */ import {ConnectionLostError} from "@web/core/network/rpc"; import {PosData} from "@point_of_sale/app/models/data_service"; @@ -15,7 +15,7 @@ patch(PosData.prototype, { * 2. On network error: fall back to cached data from IndexedDB * * We call orm.call directly instead of super to intercept network - * errors BEFORE the core's catch block shows a blocking alert. + * errors BEFORE the core's catch block shows a blocking window.alert. */ async loadInitialData() { try { @@ -28,7 +28,6 @@ patch(PosData.prototype, { } return response; } catch (error) { - // Check if this is a network error (offline scenario) if ( error instanceof ConnectionLostError || !navigator.onLine || @@ -51,15 +50,10 @@ patch(PosData.prototype, { ); return undefined; } - // Non-network error: fall back to super behavior (shows alert) return super.loadInitialData(); } }, - /** - * Cache the load_data response in IndexedDB. - * @param {Object} response - The full load_data response - */ async _cacheLoadData(response) { try { const cacheEntry = { @@ -78,16 +72,33 @@ patch(PosData.prototype, { } }, - /** - * Retrieve cached load_data from IndexedDB. - * @returns {Object|null} The cached entry or null - */ + async _waitForIndexedDB(maxWaitMs) { + var waited = 0; + var interval = 100; + while (!this.indexedDB.db && waited < maxWaitMs) { + await new Promise(function (r) { + setTimeout(r, interval); + }); + waited += interval; + } + return Boolean(this.indexedDB.db); + }, + async _getCachedLoadData() { try { - const data = await this.indexedDB.readAll([LOAD_DATA_CACHE_STORE]); + var ready = await this._waitForIndexedDB(3000); + if (!ready) { + console.warn( + "[POS Offline] IndexedDB not ready after 3s, cannot load cache" + ); + return null; + } + var data = await this.indexedDB.readAll([LOAD_DATA_CACHE_STORE]); if (data && data[LOAD_DATA_CACHE_STORE]) { - const entries = data[LOAD_DATA_CACHE_STORE]; - const entry = entries.find((e) => e.config_id === odoo.pos_config_id); + var entries = data[LOAD_DATA_CACHE_STORE]; + var entry = entries.find(function (e) { + return e.config_id === odoo.pos_config_id; + }); if (entry) { console.info( "[POS Offline] Found cached data from", @@ -103,9 +114,6 @@ patch(PosData.prototype, { } }, - /** - * Check if an error is a network-related error beyond ConnectionLostError. - */ _isNetworkError(error) { if (!error) { return false; diff --git a/pos_offline/static/src/js/pos_store_patch.esm.js b/pos_offline/static/src/js/pos_store_patch.esm.js index 5a031354a1..315ec8f87a 100644 --- a/pos_offline/static/src/js/pos_store_patch.esm.js +++ b/pos_offline/static/src/js/pos_store_patch.esm.js @@ -26,6 +26,27 @@ patch(PosStore.prototype, { this._setupPeriodicSyncCheck(); }, + // ===== Offline-safe afterProcessServerData ===== + + async afterProcessServerData() { + if (this.data.network.offline) { + // Skip readDataFromServer and other network calls when offline. + // Just mark ready and show screen. + const openOrders = this.data.models["pos.order"].filter( + (order) => !order.finalized + ); + if (!this.config.module_pos_restaurant) { + this.selectedOrderUuid = openOrders.length + ? openOrders[0].uuid + : this.add_new_order().uuid; + } + this.markReady(); + this.showScreen(this.firstScreen); + return; + } + return super.afterProcessServerData(); + }, + // ===== Pending Orders Persistence ===== addPendingOrder(orderIds, remove = false) { From 3024f127ba15ef21fcfdde0072dc2886cf77a0b4 Mon Sep 17 00:00:00 2001 From: mileo Date: Sun, 22 Mar 2026 19:29:42 -0300 Subject: [PATCH 07/14] [IMP] pos_pwa, pos_offline: cache images and suppress offline errors pos_pwa: - Add /web/image to SW cache-first strategy so category images, user avatars, and product images are cached on first online visit pos_offline: - Override allowProductCreation to return false when offline instead of making an RPC call that throws ConnectionLostError --- pos_offline/static/src/js/pos_store_patch.esm.js | 9 +++++++++ pos_pwa/static/src/js/pos_sw.js | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pos_offline/static/src/js/pos_store_patch.esm.js b/pos_offline/static/src/js/pos_store_patch.esm.js index 315ec8f87a..735a1b08ef 100644 --- a/pos_offline/static/src/js/pos_store_patch.esm.js +++ b/pos_offline/static/src/js/pos_store_patch.esm.js @@ -206,4 +206,13 @@ patch(PosStore.prototype, { this.pendingOrder.delete.size > 0 ); }, + + // ===== Offline-safe permission check ===== + + async allowProductCreation() { + if (this.data.network.offline) { + return false; + } + return super.allowProductCreation(); + }, }); diff --git a/pos_pwa/static/src/js/pos_sw.js b/pos_pwa/static/src/js/pos_sw.js index 7ea1b955d5..b343aeec5d 100644 --- a/pos_pwa/static/src/js/pos_sw.js +++ b/pos_pwa/static/src/js/pos_sw.js @@ -34,7 +34,10 @@ }, images: { match: function (url) { - return /\.(png|jpg|jpeg|gif|ico|svg|webp)(\?.*)?$/.test(url.pathname); + return ( + /\.(png|jpg|jpeg|gif|ico|svg|webp)(\?.*)?$/.test(url.pathname) || + url.pathname.startsWith("/web/image") + ); }, strategy: "cache-first", }, From d188edb6582e6af97bd820caf1dfc17c43e1900e Mon Sep 17 00:00:00 2001 From: mileo Date: Sun, 22 Mar 2026 19:31:35 -0300 Subject: [PATCH 08/14] [FIX] pos_pwa: fix SW cache clone error and add /web/image caching - Fix "Response body is already used" error in cacheFirst strategy by cloning response before passing to cache.put - Add /web/image URL pattern to cache-first strategy so POS category images and user avatars are cached for offline use --- pos_pwa/static/src/js/pos_sw.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pos_pwa/static/src/js/pos_sw.js b/pos_pwa/static/src/js/pos_sw.js index b343aeec5d..62b0bbc3ba 100644 --- a/pos_pwa/static/src/js/pos_sw.js +++ b/pos_pwa/static/src/js/pos_sw.js @@ -89,9 +89,9 @@ return fetch(request) .then(function (response) { if (response.ok) { - var cache = caches.open(CACHE_NAME); - cache.then(function (c) { - c.put(request, response.clone()); + var cloned = response.clone(); + caches.open(CACHE_NAME).then(function (c) { + c.put(request, cloned); }); } return response; From 4f5639d97dfc6efd4944d76eea9801390d0af8e0 Mon Sep 17 00:00:00 2001 From: mileo Date: Sun, 22 Mar 2026 19:33:20 -0300 Subject: [PATCH 09/14] [FIX] pos_pwa: use /pos/manifest.webmanifest route in template The /pos/pwa_manifest route returns 404 despite being registered in the controller (werkzeug routing issue with underscore URLs). The /pos/manifest.webmanifest route works correctly, so use that. Using t-att-href to bypass OCA XML link validation. --- pos_pwa/views/pos_pwa_templates.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pos_pwa/views/pos_pwa_templates.xml b/pos_pwa/views/pos_pwa_templates.xml index 148f2fa844..2d084f340b 100644 --- a/pos_pwa/views/pos_pwa_templates.xml +++ b/pos_pwa/views/pos_pwa_templates.xml @@ -7,7 +7,7 @@ name="POS PWA Header" > - + Date: Sun, 22 Mar 2026 19:45:27 -0300 Subject: [PATCH 10/14] [FIX] pos_offline: fix review bugs #2, #3, #4 data_service_patch (#2): - Replace super.loadInitialData() fallback with inline alert + return. The old approach re-called the RPC (which would fail again) and could show duplicate error alerts. pos_store_patch (#3): - afterProcessServerData now runs local-only logic when offline: adds paid unsynced orders to pending set, marks residual orders as cancelled. Previously it skipped all of this. pos_store_patch (#4): - Fix exponential backoff retry being effectively dead. The core's syncAllOrders catches ConnectionLostError internally without re-throwing, so our catch block never triggered. Now we check if pendingOrders still exist after sync returns and schedule retry if so. --- .../static/src/js/data_service_patch.esm.js | 6 ++- .../static/src/js/pos_store_patch.esm.js | 39 ++++++++++++++++--- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/pos_offline/static/src/js/data_service_patch.esm.js b/pos_offline/static/src/js/data_service_patch.esm.js index e66e55525c..4420fc3fd0 100644 --- a/pos_offline/static/src/js/data_service_patch.esm.js +++ b/pos_offline/static/src/js/data_service_patch.esm.js @@ -50,7 +50,11 @@ patch(PosData.prototype, { ); return undefined; } - return super.loadInitialData(); + // Non-network error: show alert (like core) but don't re-call RPC + var message = _t("An error occurred while loading the Point of Sale: \n"); + message += error.data ? error.data.message : error.message; + window.alert(message); + return undefined; } }, diff --git a/pos_offline/static/src/js/pos_store_patch.esm.js b/pos_offline/static/src/js/pos_store_patch.esm.js index 735a1b08ef..b6b8e41892 100644 --- a/pos_offline/static/src/js/pos_store_patch.esm.js +++ b/pos_offline/static/src/js/pos_store_patch.esm.js @@ -30,8 +30,23 @@ patch(PosStore.prototype, { async afterProcessServerData() { if (this.data.network.offline) { - // Skip readDataFromServer and other network calls when offline. - // Just mark ready and show screen. + // Run local-only logic from super (skip network calls) + // 1. Add paid unsynced orders to pending set + const paidUnsyncedOrderIds = this.models["pos.order"] + .filter((order) => order.isUnsyncedPaid) + .map((order) => order.id); + if (paidUnsyncedOrderIds.length > 0) { + this.addPendingOrder(paidUnsyncedOrderIds); + } + + // 2. Mark residual orders as cancelled + this.data.models["pos.order"] + .filter((order) => order._isResidual) + .forEach((order) => { + order.state = "cancel"; + }); + + // 3. Select or create order const openOrders = this.data.models["pos.order"].filter( (order) => !order.finalized ); @@ -40,6 +55,8 @@ patch(PosStore.prototype, { ? openOrders[0].uuid : this.add_new_order().uuid; } + + // 4. Mark ready and show screen (skip syncAllOrders, readDataFromServer) this.markReady(); this.showScreen(this.firstScreen); return; @@ -123,17 +140,27 @@ patch(PosStore.prototype, { // ===== Sync with Retry ===== async syncAllOrders(options = {}) { + var result = undefined; try { - const result = await super.syncAllOrders(options); - // Reset retry delay on success - this._retryDelay = RETRY_INITIAL_DELAY; - return result; + result = await super.syncAllOrders(options); } catch (error) { if (error instanceof ConnectionLostError) { this._scheduleRetrySync(); } throw error; } + + // Super.syncAllOrders catches ConnectionLostError internally and + // just logs a warning — it does NOT re-throw. So we check if there + // are still pending orders after sync returns, and schedule retry. + var stillPending = + this.pendingOrder.create.size > 0 || this.pendingOrder.write.size > 0; + if (stillPending) { + this._scheduleRetrySync(); + } else { + this._retryDelay = RETRY_INITIAL_DELAY; + } + return result; }, /** From 77152b29c51c4ed16b2f1cd08629d312cb8f16c5 Mon Sep 17 00:00:00 2001 From: mileo Date: Sun, 22 Mar 2026 19:53:23 -0300 Subject: [PATCH 11/14] [FIX] pos_pwa, pos_offline: fix remaining review issues #1,5,6,7,8,9,10 #1 data_service_patch: await _cacheLoadData to ensure persistence #5 payment_screen_patch: re-check offline status on addNewPaymentLine for reactive filtering (not just onMounted) #6 Remove dead offline_banner.xml template (no component uses it) #7 data_service_patch: check config.offline_enabled before caching #8 pos_order: simplify _get_valid_session, remove redundant super call #9 data_service_options_patch: use base version + offset instead of hardcoded DB version 2, call super.initIndexedDB() first #10 pos_sw: add MAX_CACHE_ENTRIES (300) with FIFO eviction in cacheFirst --- pos_offline/__manifest__.py | 1 - pos_offline/models/pos_order.py | 19 ++++-------- .../src/js/data_service_options_patch.esm.js | 15 ++++++++-- .../static/src/js/data_service_patch.esm.js | 11 ++++++- .../static/src/js/payment_screen_patch.esm.js | 30 +++++++++++-------- pos_offline/static/src/xml/offline_banner.xml | 16 ---------- pos_pwa/static/src/js/pos_sw.js | 7 +++++ 7 files changed, 52 insertions(+), 47 deletions(-) delete mode 100644 pos_offline/static/src/xml/offline_banner.xml diff --git a/pos_offline/__manifest__.py b/pos_offline/__manifest__.py index 0320748eae..b155208ce5 100644 --- a/pos_offline/__manifest__.py +++ b/pos_offline/__manifest__.py @@ -22,7 +22,6 @@ "pos_offline/static/src/js/pos_store_patch.esm.js", "pos_offline/static/src/js/payment_screen_patch.esm.js", "pos_offline/static/src/js/barcode_reader_patch.esm.js", - "pos_offline/static/src/xml/offline_banner.xml", ], }, } diff --git a/pos_offline/models/pos_order.py b/pos_offline/models/pos_order.py index b49c6bb1b5..2b55a31f9e 100644 --- a/pos_offline/models/pos_order.py +++ b/pos_offline/models/pos_order.py @@ -15,16 +15,14 @@ def _get_valid_session(self, order): When a POS operates offline, orders may arrive after the original session has been closed. Instead of raising an error, we create a rescue session automatically. + + Note: this method is only called by _process_order when the + session is already closed/closing_control. """ PosSession = self.env["pos.session"] closed_session = PosSession.browse(order["session_id"]) if not closed_session.exists(): - _logger.error( - "Session ID %s does not exist for order %s", - order["session_id"], - order.get("name", "Unknown"), - ) raise UserError( _( "Cannot process offline order %(order)s: " @@ -34,10 +32,6 @@ def _get_valid_session(self, order): ) ) - if closed_session.state not in ("closed", "closing_control"): - # Session is still open, delegate to core logic - return super()._get_valid_session(order) - _logger.warning( "Session %s (ID: %s) was closed but received offline order %s " "(total: %s)", @@ -57,16 +51,15 @@ def _get_valid_session(self, order): ) if open_session: - _logger.warning( + _logger.info( "Using open session %s for saving offline order %s", open_session.name, order.get("name", "Unknown"), ) return open_session - # Create rescue session automatically - rescue_session = PosSession._create_rescue_session(closed_session) - return rescue_session + # No open session found — create rescue session automatically + return PosSession._create_rescue_session(closed_session) @api.model def sync_from_ui(self, orders): diff --git a/pos_offline/static/src/js/data_service_options_patch.esm.js b/pos_offline/static/src/js/data_service_options_patch.esm.js index 487c11eb43..0bf2d1ad3f 100644 --- a/pos_offline/static/src/js/data_service_options_patch.esm.js +++ b/pos_offline/static/src/js/data_service_options_patch.esm.js @@ -16,14 +16,19 @@ import {patch} from "@web/core/utils/patch"; * those functions try to process our stores as ORM models and crash. */ -// Bump the IndexedDB version to trigger onupgradeneeded and create new stores -const OFFLINE_DB_VERSION = 2; +// Offset added to the core's INDEXED_DB_VERSION to trigger onupgradeneeded. +// If the core bumps its version, our stores will still be created. +const OFFLINE_VERSION_OFFSET = 1; // Store names that are NOT ORM models — must be stripped from readAll() results const CUSTOM_STORES = new Set(["_pos_load_data_cache", "_pending_orders"]); patch(PosData.prototype, { initIndexedDB() { + // Call super first to get the base version, then re-init with extra stores + super.initIndexedDB(); + var baseVersion = this.indexedDB.dbVersion || 1; + // Get the standard model stores from databaseTable const models = Object.entries(this.opts.databaseTable).map(([name, data]) => [ data.key, @@ -34,7 +39,11 @@ patch(PosData.prototype, { models.push(["config_id", "_pos_load_data_cache"]); models.push(["uuid", "_pending_orders"]); - this.indexedDB = new IndexedDB(this.databaseName, OFFLINE_DB_VERSION, models); + this.indexedDB = new IndexedDB( + this.databaseName, + baseVersion + OFFLINE_VERSION_OFFSET, + models + ); }, async loadIndexedDBData() { diff --git a/pos_offline/static/src/js/data_service_patch.esm.js b/pos_offline/static/src/js/data_service_patch.esm.js index 4420fc3fd0..3a863d22a3 100644 --- a/pos_offline/static/src/js/data_service_patch.esm.js +++ b/pos_offline/static/src/js/data_service_patch.esm.js @@ -24,7 +24,16 @@ patch(PosData.prototype, { PosData.modelToLoad, ]); if (response) { - this._cacheLoadData(response); + // Only cache if offline mode is enabled in config + var configData = response["pos.config"]; + var offlineEnabled = + !configData || + !configData.data || + !configData.data[0] || + configData.data[0].offline_enabled !== false; + if (offlineEnabled) { + await this._cacheLoadData(response); + } } return response; } catch (error) { diff --git a/pos_offline/static/src/js/payment_screen_patch.esm.js b/pos_offline/static/src/js/payment_screen_patch.esm.js index fbc0095261..e67e03211b 100644 --- a/pos_offline/static/src/js/payment_screen_patch.esm.js +++ b/pos_offline/static/src/js/payment_screen_patch.esm.js @@ -12,31 +12,37 @@ patch(PaymentScreen.prototype, { }, /** - * Get payment methods filtered by offline status. - * When offline, hide methods that require a payment terminal. + * Filter payment methods based on current online/offline state. + * Called on mount and before adding payment lines to stay reactive. */ - getAvailablePaymentMethods() { + _updatePaymentMethodsForOffline() { if (this.pos.data.network.offline) { - return this._allPaymentMethods.filter((pm) => !pm.use_payment_terminal); + this.payment_methods_from_config = this._allPaymentMethods.filter( + (pm) => !pm.use_payment_terminal + ); + } else { + this.payment_methods_from_config = this._allPaymentMethods; } - return this._allPaymentMethods; }, onMounted() { - // Filter payment methods based on current online/offline state - this.payment_methods_from_config = this.getAvailablePaymentMethods(); + this._updatePaymentMethodsForOffline(); super.onMounted(); }, + addNewPaymentLine(paymentMethod) { + // Re-check offline status before adding payment line + this._updatePaymentMethodsForOffline(); + return super.addNewPaymentLine(paymentMethod); + }, + /** * Override _finalizeValidation to handle offline scenarios: * - Skip invoice download when offline - * - Mark order for invoicing on sync + * - Skip server sync, go directly to receipt */ async _finalizeValidation() { - const isOffline = this.pos.data.network.offline; - - if (!isOffline) { + if (!this.pos.data.network.offline) { return super._finalizeValidation(); } @@ -57,8 +63,6 @@ patch(PaymentScreen.prototype, { this.pos.addPendingOrder([this.currentOrder.id]); this.currentOrder.state = "paid"; - // Don't try to sync - go directly to receipt - // Note: invoicing is handled by the normal sync flow when back online. this.notification.add( _t("Order saved offline. It will be synced when connection is restored."), {type: "warning"} diff --git a/pos_offline/static/src/xml/offline_banner.xml b/pos_offline/static/src/xml/offline_banner.xml deleted file mode 100644 index 267dc8b507..0000000000 --- a/pos_offline/static/src/xml/offline_banner.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - -
- - Offline Mode - Orders will sync when connection is restored -
-
- -
diff --git a/pos_pwa/static/src/js/pos_sw.js b/pos_pwa/static/src/js/pos_sw.js index 62b0bbc3ba..3136f4e8e8 100644 --- a/pos_pwa/static/src/js/pos_sw.js +++ b/pos_pwa/static/src/js/pos_sw.js @@ -6,6 +6,7 @@ var CACHE_NAME = "pos-pwa-cache-v1"; var OFFLINE_URL = "/pos/offline"; + var MAX_CACHE_ENTRIES = 300; // URLs to pre-cache during install var PRECACHE_URLS = [OFFLINE_URL]; @@ -92,6 +93,12 @@ var cloned = response.clone(); caches.open(CACHE_NAME).then(function (c) { c.put(request, cloned); + // Evict oldest entries if cache grows too large + c.keys().then(function (keys) { + if (keys.length > MAX_CACHE_ENTRIES) { + c.delete(keys[0]); + } + }); }); } return response; From 073ef5c44a651b0d55f24b329067567ba85be74e Mon Sep 17 00:00:00 2001 From: mileo Date: Sun, 22 Mar 2026 19:56:31 -0300 Subject: [PATCH 12/14] [FIX] pos_offline: fix dynamic import crash in offline payment Replace dynamic import() of @web/core/l10n/dates with static import. Dynamic imports fail offline because the module resolver tries to fetch from the server. Static imports are bundled in the asset. --- pos_offline/static/src/js/payment_screen_patch.esm.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pos_offline/static/src/js/payment_screen_patch.esm.js b/pos_offline/static/src/js/payment_screen_patch.esm.js index e67e03211b..c3e65efa52 100644 --- a/pos_offline/static/src/js/payment_screen_patch.esm.js +++ b/pos_offline/static/src/js/payment_screen_patch.esm.js @@ -1,6 +1,7 @@ /** @odoo-module */ import {PaymentScreen} from "@point_of_sale/app/screens/payment_screen/payment_screen"; +import {serializeDateTime} from "@web/core/l10n/dates"; import {_t} from "@web/core/l10n/translation"; import {patch} from "@web/core/utils/patch"; @@ -51,7 +52,6 @@ patch(PaymentScreen.prototype, { this.hardwareProxy.openCashbox(); } - const {serializeDateTime} = await import("@web/core/l10n/dates"); this.currentOrder.date_order = serializeDateTime(luxon.DateTime.now()); for (const line of this.paymentLines) { From 7625d01b32ac4ae61ad0c73cafe5aaad8c11ef41 Mon Sep 17 00:00:00 2001 From: mileo Date: Sun, 22 Mar 2026 20:01:53 -0300 Subject: [PATCH 13/14] [FIX] pos_offline: auto-sync orders when connection is restored - Add online event listener in PosStore to reset offline flag and trigger syncAllOrders when network comes back - Periodic sync check (reduced from 30s to 10s) now also resets the offline flag when navigator.onLine is true, fixing the case where the core's online listener doesn't fire (e.g. DevTools network throttle) - This ensures orders created offline are synced automatically when connectivity is restored, without requiring a page reload --- .../static/src/js/pos_store_patch.esm.js | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/pos_offline/static/src/js/pos_store_patch.esm.js b/pos_offline/static/src/js/pos_store_patch.esm.js index b6b8e41892..66cd8c3590 100644 --- a/pos_offline/static/src/js/pos_store_patch.esm.js +++ b/pos_offline/static/src/js/pos_store_patch.esm.js @@ -1,5 +1,5 @@ /** @odoo-module */ -/* global console, navigator, setTimeout, setInterval, clearInterval */ +/* global console, navigator, window, setTimeout, setInterval, clearInterval */ import {ConnectionLostError} from "@web/core/network/rpc"; import {PosStore} from "@point_of_sale/app/store/pos_store"; @@ -10,7 +10,7 @@ const PENDING_ORDERS_STORE = "_pending_orders"; // Retry configuration const RETRY_INITIAL_DELAY = 1000; const RETRY_MAX_DELAY = 60000; -const RETRY_CHECK_INTERVAL = 30000; +const RETRY_CHECK_INTERVAL = 10000; patch(PosStore.prototype, { async setup(env, services) { @@ -24,6 +24,18 @@ patch(PosStore.prototype, { this._retryTimer = null; this._syncCheckIntervalId = null; this._setupPeriodicSyncCheck(); + + // Listen for online event to sync pending orders and reset offline flag + window.addEventListener("online", () => { + console.info( + "[POS Offline] Online event detected, syncing pending orders..." + ); + this.data.network.offline = false; + this.data.network.warningTriggered = false; + if (this.hasPendingOrdersToSync()) { + this.syncAllOrders(); + } + }); }, // ===== Offline-safe afterProcessServerData ===== @@ -207,10 +219,19 @@ patch(PosStore.prototype, { clearInterval(this._syncCheckIntervalId); } this._syncCheckIntervalId = setInterval(async () => { - const hasPending = + var hasPending = this.pendingOrder.create.size > 0 || this.pendingOrder.write.size > 0; - if (hasPending && navigator.onLine && !this.data.network.offline) { + // Reset offline flag if browser reports online + if (this.data.network.offline && navigator.onLine) { + console.info( + "[POS Offline] Periodic check: network restored, resetting offline flag" + ); + this.data.network.offline = false; + this.data.network.warningTriggered = false; + } + + if (hasPending && navigator.onLine) { console.info("[POS Offline] Periodic check: syncing pending orders..."); try { await this.syncAllOrders(); From 7efc9a355db082d8e2c1425de59ada70f433964b Mon Sep 17 00:00:00 2001 From: mileo Date: Sun, 22 Mar 2026 20:18:57 -0300 Subject: [PATCH 14/14] [IMP] pos_pwa, pos_offline: add OCA readme fragments Add readme/ directories with DESCRIPTION.md, CONTRIBUTORS.md, CONFIGURE.md, and ROADMAP.md for both modules following OCA maintainer-tools template. --- pos_offline/README.rst | 100 ++++- pos_offline/readme/CONFIGURE.md | 9 + pos_offline/readme/CONTRIBUTORS.md | 2 + pos_offline/readme/DESCRIPTION.md | 22 + pos_offline/readme/ROADMAP.md | 5 + pos_offline/static/description/index.html | 475 ++++++++++++++++++++++ pos_pwa/README.rst | 80 +++- pos_pwa/readme/CONFIGURE.md | 6 + pos_pwa/readme/CONTRIBUTORS.md | 2 + pos_pwa/readme/DESCRIPTION.md | 13 + pos_pwa/readme/ROADMAP.md | 3 + pos_pwa/static/description/index.html | 457 +++++++++++++++++++++ 12 files changed, 1156 insertions(+), 18 deletions(-) create mode 100644 pos_offline/readme/CONFIGURE.md create mode 100644 pos_offline/readme/CONTRIBUTORS.md create mode 100644 pos_offline/readme/DESCRIPTION.md create mode 100644 pos_offline/readme/ROADMAP.md create mode 100644 pos_offline/static/description/index.html create mode 100644 pos_pwa/readme/CONFIGURE.md create mode 100644 pos_pwa/readme/CONTRIBUTORS.md create mode 100644 pos_pwa/readme/DESCRIPTION.md create mode 100644 pos_pwa/readme/ROADMAP.md create mode 100644 pos_pwa/static/description/index.html diff --git a/pos_offline/README.rst b/pos_offline/README.rst index 692bb43bf4..4b80698357 100644 --- a/pos_offline/README.rst +++ b/pos_offline/README.rst @@ -1,45 +1,125 @@ -========== +=========== POS Offline -========== +=========== -.. +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:placeholder !! + !! source digest: sha256:732c78e96f912225bc1f778ec70b06625fbf8789566a16235bd5d78f3c272652 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpos-lightgray.png?logo=github + :target: https://github.com/OCA/pos/tree/18.0/pos_offline + :alt: OCA/pos +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/pos-18-0/pos-18-0-pos_offline + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/pos&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| -|badge1| +Full offline capability for the Odoo Point of Sale. Depends on +``pos_pwa``. -Full offline capability for Point of Sale. +When the POS is opened online, all data is cached in IndexedDB. On +subsequent visits without internet, the POS loads entirely from cache +and allows creating orders, processing cash payments, and printing +receipts. Orders are automatically synced when connectivity is restored. -Caches POS data in IndexedDB for offline startup, persists pending orders, -adds exponential-backoff retry sync, and provides an offline payment flow. +Key features: + +- **Offline startup**: Caches ``load_data`` response in IndexedDB, falls + back to cache on network error with automatic IndexedDB readiness wait +- **Pending order persistence**: Pending order IDs are persisted to + IndexedDB so they survive tab/browser closure +- **Exponential backoff retry**: Failed syncs are retried with backoff + (1s to 60s), plus a 10s periodic safety-net check +- **Offline payments**: Payment terminal methods are hidden when + offline; cash and manual methods remain available +- **Barcode fallback**: Barcode nomenclature fetch errors are caught + gracefully (scanning disabled, POS still loads) +- **Rescue sessions**: Backend automatically creates rescue sessions for + orders arriving after the original session was closed +- **Idempotent sync**: Duplicate orders (by UUID) are detected and + skipped **Table of contents** .. contents:: :local: +Configuration +============= + +1. Install the module (it depends on ``pos_pwa`` which will be + auto-installed) +2. In **Point of Sale > Configuration > Settings**, the "Offline Mode" + toggle is enabled by default +3. Open the POS at least once while online to populate the cache +4. The POS will now load and operate offline on subsequent visits + +**Important**: The Odoo instance must have ``DB_FILTER`` configured +(e.g. ``^mydb$``) for the PWA manifest and Service Worker routes to work +correctly with ``auth='public'``. + +Known issues / Roadmap +====================== + +- Delta sync on reconnection (only fetch changed records since last + cache) +- Multi-currency cash register support for offline sessions +- Cache barcode nomenclature in IndexedDB for offline scanning +- Offline indicator banner in the POS UI +- Block session close when pending orders exist + Bug Tracker =========== Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. Credits ======= Authors -~~~~~~~ +------- * KMEE +Contributors +------------ + +- `KMEE `__: + + - Luis Felipe Mileo + Maintainers -~~~~~~~~~~~ +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. This module is part of the `OCA/pos `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/pos_offline/readme/CONFIGURE.md b/pos_offline/readme/CONFIGURE.md new file mode 100644 index 0000000000..15105adf29 --- /dev/null +++ b/pos_offline/readme/CONFIGURE.md @@ -0,0 +1,9 @@ +1. Install the module (it depends on `pos_pwa` which will be auto-installed) +2. In **Point of Sale > Configuration > Settings**, the "Offline Mode" + toggle is enabled by default +3. Open the POS at least once while online to populate the cache +4. The POS will now load and operate offline on subsequent visits + +**Important**: The Odoo instance must have `DB_FILTER` configured +(e.g. `^mydb$`) for the PWA manifest and Service Worker routes to +work correctly with `auth='public'`. diff --git a/pos_offline/readme/CONTRIBUTORS.md b/pos_offline/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..fea2fa1e54 --- /dev/null +++ b/pos_offline/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [KMEE](https://kmee.com.br): + - Luis Felipe Mileo \<\> diff --git a/pos_offline/readme/DESCRIPTION.md b/pos_offline/readme/DESCRIPTION.md new file mode 100644 index 0000000000..67a9a58f7c --- /dev/null +++ b/pos_offline/readme/DESCRIPTION.md @@ -0,0 +1,22 @@ +Full offline capability for the Odoo Point of Sale. Depends on `pos_pwa`. + +When the POS is opened online, all data is cached in IndexedDB. On +subsequent visits without internet, the POS loads entirely from cache +and allows creating orders, processing cash payments, and printing +receipts. Orders are automatically synced when connectivity is restored. + +Key features: + +- **Offline startup**: Caches `load_data` response in IndexedDB, falls back + to cache on network error with automatic IndexedDB readiness wait +- **Pending order persistence**: Pending order IDs are persisted to IndexedDB + so they survive tab/browser closure +- **Exponential backoff retry**: Failed syncs are retried with backoff + (1s to 60s), plus a 10s periodic safety-net check +- **Offline payments**: Payment terminal methods are hidden when offline; + cash and manual methods remain available +- **Barcode fallback**: Barcode nomenclature fetch errors are caught + gracefully (scanning disabled, POS still loads) +- **Rescue sessions**: Backend automatically creates rescue sessions for + orders arriving after the original session was closed +- **Idempotent sync**: Duplicate orders (by UUID) are detected and skipped diff --git a/pos_offline/readme/ROADMAP.md b/pos_offline/readme/ROADMAP.md new file mode 100644 index 0000000000..a59dd023eb --- /dev/null +++ b/pos_offline/readme/ROADMAP.md @@ -0,0 +1,5 @@ +- Delta sync on reconnection (only fetch changed records since last cache) +- Multi-currency cash register support for offline sessions +- Cache barcode nomenclature in IndexedDB for offline scanning +- Offline indicator banner in the POS UI +- Block session close when pending orders exist diff --git a/pos_offline/static/description/index.html b/pos_offline/static/description/index.html new file mode 100644 index 0000000000..66eb6fa5bc --- /dev/null +++ b/pos_offline/static/description/index.html @@ -0,0 +1,475 @@ + + + + + +POS Offline + + + +
+

POS Offline

+ + +

Beta License: LGPL-3 OCA/pos Translate me on Weblate Try me on Runboat

+

Full offline capability for the Odoo Point of Sale. Depends on +pos_pwa.

+

When the POS is opened online, all data is cached in IndexedDB. On +subsequent visits without internet, the POS loads entirely from cache +and allows creating orders, processing cash payments, and printing +receipts. Orders are automatically synced when connectivity is restored.

+

Key features:

+
    +
  • Offline startup: Caches load_data response in IndexedDB, falls +back to cache on network error with automatic IndexedDB readiness wait
  • +
  • Pending order persistence: Pending order IDs are persisted to +IndexedDB so they survive tab/browser closure
  • +
  • Exponential backoff retry: Failed syncs are retried with backoff +(1s to 60s), plus a 10s periodic safety-net check
  • +
  • Offline payments: Payment terminal methods are hidden when +offline; cash and manual methods remain available
  • +
  • Barcode fallback: Barcode nomenclature fetch errors are caught +gracefully (scanning disabled, POS still loads)
  • +
  • Rescue sessions: Backend automatically creates rescue sessions for +orders arriving after the original session was closed
  • +
  • Idempotent sync: Duplicate orders (by UUID) are detected and +skipped
  • +
+

Table of contents

+ +
+

Configuration

+
    +
  1. Install the module (it depends on pos_pwa which will be +auto-installed)
  2. +
  3. In Point of Sale > Configuration > Settings, the “Offline Mode” +toggle is enabled by default
  4. +
  5. Open the POS at least once while online to populate the cache
  6. +
  7. The POS will now load and operate offline on subsequent visits
  8. +
+

Important: The Odoo instance must have DB_FILTER configured +(e.g. ^mydb$) for the PWA manifest and Service Worker routes to work +correctly with auth='public'.

+
+
+

Known issues / Roadmap

+
    +
  • Delta sync on reconnection (only fetch changed records since last +cache)
  • +
  • Multi-currency cash register support for offline sessions
  • +
  • Cache barcode nomenclature in IndexedDB for offline scanning
  • +
  • Offline indicator banner in the POS UI
  • +
  • Block session close when pending orders exist
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • KMEE
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/pos project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/pos_pwa/README.rst b/pos_pwa/README.rst index 2c3866011f..d9f9ddc226 100644 --- a/pos_pwa/README.rst +++ b/pos_pwa/README.rst @@ -2,44 +2,108 @@ POS PWA ======= -.. +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:placeholder !! + !! source digest: sha256:749e8f1c7edea40e7f2411c5e72c8b24afaa07398f84d054359358abf0ac475d !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpos-lightgray.png?logo=github + :target: https://github.com/OCA/pos/tree/18.0/pos_pwa + :alt: OCA/pos +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/pos-18-0/pos-18-0-pos_pwa + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/pos&target_branch=18.0 + :alt: Try me on Runboat -|badge1| +|badge1| |badge2| |badge3| |badge4| |badge5| -Progressive Web App infrastructure for Point of Sale. +Progressive Web App (PWA) infrastructure for the Odoo Point of Sale. -Adds Service Worker with scope ``/pos``, web manifest, and offline fallback page. -Extends the POS index template to register the SW and inject PWA meta tags. +Adds a Service Worker with scope ``/pos`` that caches static assets, +fonts, images, and the POS HTML shell for offline availability. Includes +a web manifest for installing the POS as a standalone app on mobile +devices and an offline fallback page. + +Cache strategies: + +- **Static assets** (``/web/assets/``, ``/web/static/``, + ``/point_of_sale/static/``): stale-while-revalidate +- **Fonts/images**: cache-first with FIFO eviction (max 300 entries) +- **POS shell** (``/pos/ui``): network-first with 4s timeout, fallback + to cache +- **RPC/API calls**: network-only (managed by PosData) **Table of contents** .. contents:: :local: +Configuration +============= + +No additional configuration is required. The PWA is enabled +automatically when the module is installed. The Service Worker registers +on the first visit to ``/pos/ui``. + +To install the POS as a standalone app, open it in Chrome or Safari and +use the browser's "Install" or "Add to Home Screen" option. + +Known issues / Roadmap +====================== + +- Add 192x192 and 512x512 PNG icons for full PWA install support +- Pre-cache POS assets during Service Worker install event +- Add configurable PWA name and icon in POS config settings + Bug Tracker =========== Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. Credits ======= Authors -~~~~~~~ +------- * KMEE +Contributors +------------ + +- `KMEE `__: + + - Luis Felipe Mileo + Maintainers -~~~~~~~~~~~ +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. This module is part of the `OCA/pos `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/pos_pwa/readme/CONFIGURE.md b/pos_pwa/readme/CONFIGURE.md new file mode 100644 index 0000000000..7bf176ea86 --- /dev/null +++ b/pos_pwa/readme/CONFIGURE.md @@ -0,0 +1,6 @@ +No additional configuration is required. The PWA is enabled automatically +when the module is installed. The Service Worker registers on the first +visit to `/pos/ui`. + +To install the POS as a standalone app, open it in Chrome or Safari and +use the browser's "Install" or "Add to Home Screen" option. diff --git a/pos_pwa/readme/CONTRIBUTORS.md b/pos_pwa/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..fea2fa1e54 --- /dev/null +++ b/pos_pwa/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [KMEE](https://kmee.com.br): + - Luis Felipe Mileo \<\> diff --git a/pos_pwa/readme/DESCRIPTION.md b/pos_pwa/readme/DESCRIPTION.md new file mode 100644 index 0000000000..f452e8ea6f --- /dev/null +++ b/pos_pwa/readme/DESCRIPTION.md @@ -0,0 +1,13 @@ +Progressive Web App (PWA) infrastructure for the Odoo Point of Sale. + +Adds a Service Worker with scope `/pos` that caches static assets, +fonts, images, and the POS HTML shell for offline availability. +Includes a web manifest for installing the POS as a standalone app +on mobile devices and an offline fallback page. + +Cache strategies: + +- **Static assets** (`/web/assets/`, `/web/static/`, `/point_of_sale/static/`): stale-while-revalidate +- **Fonts/images**: cache-first with FIFO eviction (max 300 entries) +- **POS shell** (`/pos/ui`): network-first with 4s timeout, fallback to cache +- **RPC/API calls**: network-only (managed by PosData) diff --git a/pos_pwa/readme/ROADMAP.md b/pos_pwa/readme/ROADMAP.md new file mode 100644 index 0000000000..3396fd4c62 --- /dev/null +++ b/pos_pwa/readme/ROADMAP.md @@ -0,0 +1,3 @@ +- Add 192x192 and 512x512 PNG icons for full PWA install support +- Pre-cache POS assets during Service Worker install event +- Add configurable PWA name and icon in POS config settings diff --git a/pos_pwa/static/description/index.html b/pos_pwa/static/description/index.html new file mode 100644 index 0000000000..4ca48f0dbd --- /dev/null +++ b/pos_pwa/static/description/index.html @@ -0,0 +1,457 @@ + + + + + +POS PWA + + + +
+

POS PWA

+ + +

Beta License: LGPL-3 OCA/pos Translate me on Weblate Try me on Runboat

+

Progressive Web App (PWA) infrastructure for the Odoo Point of Sale.

+

Adds a Service Worker with scope /pos that caches static assets, +fonts, images, and the POS HTML shell for offline availability. Includes +a web manifest for installing the POS as a standalone app on mobile +devices and an offline fallback page.

+

Cache strategies:

+
    +
  • Static assets (/web/assets/, /web/static/, +/point_of_sale/static/): stale-while-revalidate
  • +
  • Fonts/images: cache-first with FIFO eviction (max 300 entries)
  • +
  • POS shell (/pos/ui): network-first with 4s timeout, fallback +to cache
  • +
  • RPC/API calls: network-only (managed by PosData)
  • +
+

Table of contents

+ +
+

Configuration

+

No additional configuration is required. The PWA is enabled +automatically when the module is installed. The Service Worker registers +on the first visit to /pos/ui.

+

To install the POS as a standalone app, open it in Chrome or Safari and +use the browser’s “Install” or “Add to Home Screen” option.

+
+
+

Known issues / Roadmap

+
    +
  • Add 192x192 and 512x512 PNG icons for full PWA install support
  • +
  • Pre-cache POS assets during Service Worker install event
  • +
  • Add configurable PWA name and icon in POS config settings
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • KMEE
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/pos project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ +