From 08c4e3711bb4d6d4c86116deb76b5d6d5b241217 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Thu, 7 May 2026 15:33:20 +0700 Subject: [PATCH] First Commit --- .gitignore | 6 +++ README.rst | 36 ++++++++++++++ __init__.py | 1 + __manifest__.py | 22 +++++++++ models/__init__.py | 1 + models/pos_order_line.py | 6 +++ static/src/app/models/pos_order.js | 33 +++++++++++++ static/src/app/models/pos_order_line.js | 48 +++++++++++++++++++ .../order_summary/order_summary.js | 33 +++++++++++++ 9 files changed, 186 insertions(+) create mode 100644 .gitignore create mode 100644 README.rst create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 models/__init__.py create mode 100644 models/pos_order_line.py create mode 100644 static/src/app/models/pos_order.js create mode 100644 static/src/app/models/pos_order_line.js create mode 100644 static/src/app/screens/product_screen/order_summary/order_summary.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf51bee --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*$py.class +.DS_Store +*.swp +*.swo diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..cac577b --- /dev/null +++ b/README.rst @@ -0,0 +1,36 @@ +======================== +POS Lock Sent Products +======================== + +This module implements a robust security lock in the Odoo 19 Point of Sale system to prevent unauthorized deletion or reduction of order lines that have already been sent to the kitchen (Preparation). + +Features +======== + +* **Role-Based Security**: Only users with the "Area Manager" role (integrated with `pos_employee_role`) can delete or reduce the quantity of items already sent to the kitchen. +* **Persistent Locking**: Uses a database-backed field `x_locked_qty` on `pos.order.line` to ensure the lock state persists across browser refreshes, device synchronizations, and session restarts. +* **Graceful Reduction**: Allows cashiers to freely add and remove "new" quantities on an existing line. For example, if 1 item is sent and 1 is newly added (Total 2), the cashier can safely backspace to reduce the quantity to 1, but cannot drop it below the sent amount. +* **User-Friendly Alerts**: Displays a clear "Action Restricted" popup when an unauthorized deletion attempt is blocked. + +Technical Details +================= + +* **Models Patched**: + * ``pos.order.line``: Added ``x_locked_qty`` to store the snapshot of the quantity at the moment of kitchen preparation. +* **Javascript Patches**: + * ``PosOrderline``: Enforces quantity checks in ``setQuantity`` using both Odoo's native ``mp_qty`` and the custom ``x_locked_qty``. + * ``PosOrder``: Intercepts ``removeOrderline`` to block full line deletions and captures snapshots during ``updateLastOrderChange``. + * ``OrderSummary``: Intercepts the UI "Remove" action to ensure the security alert is correctly displayed to the user. + +Dependencies +============ + +* ``point_of_sale`` +* ``pos_employee_role`` + +Installation +============ + +1. Install the module as usual. +2. Ensure the "Area Manager" role is configured for supervisors who require bypass authority. +3. Refresh the POS interface to load the new security logic. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..5ac0da9 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'POS Lock Sent Product', + 'version': '1.0', + 'category': 'Sales/Point of Sale', + 'summary': 'Lock product deletion/reduction after order is sent to kitchen', + 'description': """ +This module prevents cashiers from deleting or reducing the quantity of products that have already been sent to the kitchen. +Only users with the 'Area Manager' role can perform these actions. + """, + 'author': "Suherdy Yacob", + 'depends': ['point_of_sale', 'pos_hr', 'pos_employee_role'], + 'data': [], + 'assets': { + 'point_of_sale._assets_pos': [ + 'pos_lock_sent_product/static/src/app/**/*', + ], + }, + 'installable': True, + 'application': False, + 'license': 'LGPL-3', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..3aa06b8 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1 @@ +from . import pos_order_line diff --git a/models/pos_order_line.py b/models/pos_order_line.py new file mode 100644 index 0000000..b8f73a9 --- /dev/null +++ b/models/pos_order_line.py @@ -0,0 +1,6 @@ +from odoo import models, fields + +class PosOrderLine(models.Model): + _inherit = 'pos.order.line' + + x_locked_qty = fields.Float(string='Locked Quantity', default=0.0) diff --git a/static/src/app/models/pos_order.js b/static/src/app/models/pos_order.js new file mode 100644 index 0000000..023e94a --- /dev/null +++ b/static/src/app/models/pos_order.js @@ -0,0 +1,33 @@ +import { PosOrder } from "@point_of_sale/app/models/pos_order"; +import { patch } from "@web/core/utils/patch"; +import { _t } from "@web/core/l10n/translation"; + +patch(PosOrder.prototype, { + updateLastOrderChange() { + super.updateLastOrderChange(...arguments); + for (const line of this.lines) { + // Backup our lock to the persisted field + line.x_locked_qty = line.qty; + } + }, + + removeOrderline(line) { + const cashier = this.models["pos.session"].getFirst()?.cashier; + const isAreaManager = cashier?.pos_role === 'area_manager'; + + // Use the same helper as the line patch + const lockedQty = line.get_locked_qty ? line.get_locked_qty() : 0; + + if (lockedQty > 0 && !isAreaManager) { + if (line.qty > lockedQty) { + line.setQuantity(lockedQty); + return false; + } + return { + title: _t("Action Restricted"), + body: _t("You cannot delete a product that has already been sent to the kitchen. Please call an Area Manager."), + }; + } + return super.removeOrderline(...arguments); + }, +}); diff --git a/static/src/app/models/pos_order_line.js b/static/src/app/models/pos_order_line.js new file mode 100644 index 0000000..a870d4e --- /dev/null +++ b/static/src/app/models/pos_order_line.js @@ -0,0 +1,48 @@ +import { PosOrderline } from "@point_of_sale/app/models/pos_order_line"; +import { patch } from "@web/core/utils/patch"; +import { _t } from "@web/core/l10n/translation"; + +patch(PosOrderline.prototype, { + init_from_JSON(json) { + super.init_from_JSON(...arguments); + this.x_locked_qty = json.x_locked_qty || 0; + }, + + export_as_JSON() { + const json = super.export_as_JSON(...arguments); + json.x_locked_qty = this.get_locked_qty(); + return json; + }, + + get_locked_qty() { + // Use mp_qty (native Odoo sent qty) or our persisted x_locked_qty + const mpQty = parseFloat(this.mp_qty || 0); + const xLockedQty = parseFloat(this.x_locked_qty || 0); + return Math.max(mpQty, xLockedQty); + }, + + setQuantity(quantity, keep_price) { + const cashier = this.models["pos.session"].getFirst()?.cashier; + const isAreaManager = cashier?.pos_role === 'area_manager'; + + const lockedQty = this.get_locked_qty(); + const newQty = (typeof quantity === "number" || (typeof quantity === "string" && quantity !== "")) ? parseFloat(quantity) : 0; + + if (lockedQty > 0 && !isAreaManager) { + // If trying to delete or reduce below locked qty + if (quantity === "" || isNaN(parseFloat(quantity)) || newQty < lockedQty) { + // If there's unsent quantity, gracefully reduce it to the locked quantity + if (this.qty > lockedQty) { + return super.setQuantity(lockedQty, keep_price); + } + + // Otherwise, they are trying to delete the sent quantity, so block it + return { + title: _t("Action Restricted"), + body: _t("You cannot reduce the quantity below what was already sent to the kitchen. Please call an Area Manager."), + }; + } + } + return super.setQuantity(...arguments); + }, +}); diff --git a/static/src/app/screens/product_screen/order_summary/order_summary.js b/static/src/app/screens/product_screen/order_summary/order_summary.js new file mode 100644 index 0000000..ca2d7d9 --- /dev/null +++ b/static/src/app/screens/product_screen/order_summary/order_summary.js @@ -0,0 +1,33 @@ +import { OrderSummary } from "@point_of_sale/app/screens/product_screen/order_summary/order_summary"; +import { patch } from "@web/core/utils/patch"; +import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; + +patch(OrderSummary.prototype, { + _setValue(val) { + const { numpadMode } = this.pos; + let selectedLine = this.currentOrder.getSelectedOrderline(); + + // Only intercept the "remove" action to show our custom popup. + // We DO NOT intercept val === "" because Odoo's setQuantity("") handles it correctly. + if (selectedLine && numpadMode === "quantity" && val === "remove") { + if (selectedLine.combo_parent_id) { + selectedLine = selectedLine.combo_parent_id; + } + + // Call the patched removeOrderline which returns an error object if restricted + const result = this.currentOrder.removeOrderline(selectedLine); + + if (typeof result === "object") { + this.dialog.add(AlertDialog, result); + this.numberBuffer.reset(); + } else if (result === false) { + this.numberBuffer.reset(); + } + // We fully handled "remove", do not call super. + return; + } + + // For all other actions (including val === ""), let standard Odoo handle it + super._setValue(...arguments); + }, +});