diff --git a/README.rst b/README.rst index 3da7fbc..0bdec08 100644 --- a/README.rst +++ b/README.rst @@ -10,6 +10,7 @@ Features * **Role-Based Security**: Only users with the "Area Manager" or "Store Manager" (technically "outlet_manager") roles 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. +* **Backend Checkout Resilience**: Bypasses the default stock validation constraint ("You cannot change a cancelled stock move") during checkout. When a manager deletes or reduces a sent orderline, the picking's corresponding cancelled stock moves are safely ignored during quantity updates, preventing synchronization errors on checkout. * **User-Friendly Alerts**: Displays a clear "Action Restricted" popup when an unauthorized deletion attempt is blocked. Technical Details @@ -17,16 +18,21 @@ Technical Details * **Models Patched**: * ``pos.order.line``: Added ``x_locked_qty`` to store the snapshot of the quantity at the moment of kitchen preparation. + * ``stock.move``: Overrides ``write`` to separate writes on cancelled and active moves, avoiding stock validation exceptions when managers cancel sent quantities. * **Javascript Patches**: + * ``PosStore``: Binds the active ``PosStore`` service globally to `this.models.pos` during server data loading to ensure consistent, reactive cashier role checks across all prototype record layers. * ``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. + * ``TicketScreen``: Intercepts the UI delete/refund actions using ``getCashier()`` to dynamically enforce supervisor authorization. Dependencies ============ * ``point_of_sale`` +* ``pos_hr`` * ``pos_employee_role`` +* ``pos_restaurant`` +* ``stock`` Installation ============ diff --git a/__manifest__.py b/__manifest__.py index 5159b1a..af6ad93 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -9,7 +9,7 @@ This module prevents cashiers from deleting or reducing the quantity of products Only users with the 'Area Manager' or 'Store Manager' role can perform these actions. """, 'author': "Suherdy Yacob", - 'depends': ['point_of_sale', 'pos_hr', 'pos_employee_role', 'pos_restaurant'], + 'depends': ['point_of_sale', 'pos_hr', 'pos_employee_role', 'pos_restaurant', 'stock'], 'data': [], 'assets': { 'point_of_sale._assets_pos': [ diff --git a/models/__init__.py b/models/__init__.py index 3aa06b8..d28b120 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1 +1,2 @@ from . import pos_order_line +from . import stock_move diff --git a/models/stock_move.py b/models/stock_move.py new file mode 100644 index 0000000..e4dbd2d --- /dev/null +++ b/models/stock_move.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from odoo import models + +class StockMove(models.Model): + _inherit = 'stock.move' + + def write(self, vals): + if 'quantity' in vals and any(move.state == 'cancel' for move in self): + non_cancelled_moves = self.filtered(lambda m: m.state != 'cancel') + cancelled_moves = self - non_cancelled_moves + + res = True + if non_cancelled_moves: + res = super(StockMove, non_cancelled_moves).write(vals) + + if cancelled_moves and len(vals) > 1: + vals_no_qty = {k: v for k, v in vals.items() if k != 'quantity'} + super(StockMove, cancelled_moves).write(vals_no_qty) + + return res + + return super(StockMove, self).write(vals) diff --git a/static/src/app/models/pos_order.js b/static/src/app/models/pos_order.js index f13c50e..1aca9ac 100644 --- a/static/src/app/models/pos_order.js +++ b/static/src/app/models/pos_order.js @@ -17,7 +17,7 @@ patch(PosOrder.prototype, { return super.removeOrderline(...arguments); } - const cashier = this.models["pos.session"].getFirst()?.cashier; + const cashier = this.models.pos?.getCashier(); const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role); // Use the same helper as the line patch diff --git a/static/src/app/models/pos_order_line.js b/static/src/app/models/pos_order_line.js index 7150b45..d3f7794 100644 --- a/static/src/app/models/pos_order_line.js +++ b/static/src/app/models/pos_order_line.js @@ -41,7 +41,7 @@ patch(PosOrderline.prototype, { return super.setQuantity(...arguments); } - const cashier = this.models["pos.session"].getFirst()?.cashier; + const cashier = this.models.pos?.getCashier(); const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role); const lockedQty = this.get_locked_qty(); diff --git a/static/src/app/screens/ticket_screen/ticket_screen.js b/static/src/app/screens/ticket_screen/ticket_screen.js index cb5639f..a5d47a4 100644 --- a/static/src/app/screens/ticket_screen/ticket_screen.js +++ b/static/src/app/screens/ticket_screen/ticket_screen.js @@ -3,7 +3,7 @@ import { patch } from "@web/core/utils/patch"; patch(TicketScreen.prototype, { shouldHideDeleteButton(order) { - const cashier = this.pos.models["pos.session"].getFirst()?.cashier; + const cashier = this.pos.getCashier(); const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role); if (!isManager) { return true; diff --git a/static/src/app/services/pos_store.js b/static/src/app/services/pos_store.js index d536bfc..2bfbe0e 100644 --- a/static/src/app/services/pos_store.js +++ b/static/src/app/services/pos_store.js @@ -5,6 +5,14 @@ import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import { transferState } from "../transfer_state"; patch(PosStore.prototype, { + async processServerData() { + const res = await super.processServerData(...arguments); + if (this.models) { + this.models.pos = this; + } + return res; + }, + async beforeDeleteOrder(order, options = {}) { const cashier = this.getCashier(); const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role);