diff --git a/pos_offline/README.rst b/pos_offline/README.rst new file mode 100644 index 0000000000..4b80698357 --- /dev/null +++ b/pos_offline/README.rst @@ -0,0 +1,125 @@ +=========== +POS Offline +=========== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! 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| + +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** + +.. 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/__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..b155208ce5 --- /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/js/barcode_reader_patch.esm.js", + ], + }, +} 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..2b55a31f9e --- /dev/null +++ b/pos_offline/models/pos_order.py @@ -0,0 +1,101 @@ +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. + + 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(): + 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"], + ) + ) + + _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.info( + "Using open session %s for saving offline order %s", + open_session.name, + order.get("name", "Unknown"), + ) + return open_session + + # No open session found — create rescue session automatically + return PosSession._create_rescue_session(closed_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/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/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/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_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_options_patch.esm.js b/pos_offline/static/src/js/data_service_options_patch.esm.js new file mode 100644 index 0000000000..0bf2d1ad3f --- /dev/null +++ b/pos_offline/static/src/js/data_service_options_patch.esm.js @@ -0,0 +1,98 @@ +/** @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. + * + * 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. + */ + +// 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, + 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, + baseVersion + OFFLINE_VERSION_OFFSET, + 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_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..3a863d22a3 --- /dev/null +++ b/pos_offline/static/src/js/data_service_patch.esm.js @@ -0,0 +1,143 @@ +/** @odoo-module */ +/* global navigator, console, window, setTimeout */ + +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"; + +const LOAD_DATA_CACHE_STORE = "_pos_load_data_cache"; + +patch(PosData.prototype, { + /** + * Override loadInitialData to: + * 1. On success: cache the response in IndexedDB for offline use + * 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 window.alert. + */ + async loadInitialData() { + try { + const response = await this.orm.call("pos.session", "load_data", [ + odoo.pos_session_id, + PosData.modelToLoad, + ]); + if (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) { + 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; + } + // 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; + } + }, + + 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); + } + }, + + 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 { + 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]) { + 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", + entry.timestamp + ); + return entry; + } + } + return null; + } catch (error) { + console.warn("[POS Offline] Failed to read cached load_data:", error); + return null; + } + }, + + _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_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..c3e65efa52 --- /dev/null +++ b/pos_offline/static/src/js/payment_screen_patch.esm.js @@ -0,0 +1,73 @@ +/** @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"; + +patch(PaymentScreen.prototype, { + setup() { + super.setup(); + // Store all payment methods before any filtering + this._allPaymentMethods = [...this.payment_methods_from_config]; + }, + + /** + * Filter payment methods based on current online/offline state. + * Called on mount and before adding payment lines to stay reactive. + */ + _updatePaymentMethodsForOffline() { + if (this.pos.data.network.offline) { + this.payment_methods_from_config = this._allPaymentMethods.filter( + (pm) => !pm.use_payment_terminal + ); + } else { + this.payment_methods_from_config = this._allPaymentMethods; + } + }, + + onMounted() { + 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 + * - Skip server sync, go directly to receipt + */ + async _finalizeValidation() { + if (!this.pos.data.network.offline) { + return super._finalizeValidation(); + } + + // === Offline finalization flow === + if (this.currentOrder.is_paid_with_cash() || this.currentOrder.get_change()) { + this.hardwareProxy.openCashbox(); + } + + 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"; + + 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..66cd8c3590 --- /dev/null +++ b/pos_offline/static/src/js/pos_store_patch.esm.js @@ -0,0 +1,266 @@ +/** @odoo-module */ +/* global console, navigator, window, 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 = 10000; + +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(); + + // 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 ===== + + async afterProcessServerData() { + if (this.data.network.offline) { + // 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 + ); + if (!this.config.module_pos_restaurant) { + this.selectedOrderUuid = openOrders.length + ? openOrders[0].uuid + : this.add_new_order().uuid; + } + + // 4. Mark ready and show screen (skip syncAllOrders, readDataFromServer) + this.markReady(); + this.showScreen(this.firstScreen); + return; + } + return super.afterProcessServerData(); + }, + + // ===== 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 = {}) { + var result = undefined; + try { + 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; + }, + + /** + * 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 () => { + var hasPending = + this.pendingOrder.create.size > 0 || this.pendingOrder.write.size > 0; + + // 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(); + } 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 + ); + }, + + // ===== Offline-safe permission check ===== + + async allowProductCreation() { + if (this.data.network.offline) { + return false; + } + return super.allowProductCreation(); + }, +}); 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 + + + + + + + + + + diff --git a/pos_pwa/README.rst b/pos_pwa/README.rst new file mode 100644 index 0000000000..d9f9ddc226 --- /dev/null +++ b/pos_pwa/README.rst @@ -0,0 +1,109 @@ +======= +POS PWA +======= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! 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| |badge2| |badge3| |badge4| |badge5| + +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** + +.. 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/__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..cb82774bb0 --- /dev/null +++ b/pos_pwa/controllers/main.py @@ -0,0 +1,96 @@ +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/pwa_manifest", "/pos/manifest.webmanifest"], + type="http", + auth="public", + methods=["GET"], + readonly=True, + ) + def pos_manifest(self): + """Return the PWA manifest for the POS application.""" + import json + + manifest = self._get_pos_manifest() + body = json.dumps(manifest) + return request.make_response( + body, + 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 - 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/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/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/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/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.

+
+
+
+ + 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/static/src/js/pos_sw.js b/pos_pwa/static/src/js/pos_sw.js new file mode 100644 index 0000000000..3136f4e8e8 --- /dev/null +++ b/pos_pwa/static/src/js/pos_sw.js @@ -0,0 +1,256 @@ +// @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"; + var MAX_CACHE_ENTRIES = 300; + + // 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) || + url.pathname.startsWith("/web/image") + ); + }, + 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 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; + }) + .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..2d084f340b --- /dev/null +++ b/pos_pwa/views/pos_pwa_templates.xml @@ -0,0 +1,34 @@ + + + + +