feat: implement backend stock move write overrides and unify cashier role verification via PosStore global binding.

This commit is contained in:
Suherdy Yacob 2026-05-25 17:28:29 +07:00
parent f25a550871
commit 508d9f7d87
8 changed files with 42 additions and 5 deletions

View File

@ -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. * **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. * **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. * **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. * **User-Friendly Alerts**: Displays a clear "Action Restricted" popup when an unauthorized deletion attempt is blocked.
Technical Details Technical Details
@ -17,16 +18,21 @@ Technical Details
* **Models Patched**: * **Models Patched**:
* ``pos.order.line``: Added ``x_locked_qty`` to store the snapshot of the quantity at the moment of kitchen preparation. * ``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**: * **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``. * ``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``. * ``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 Dependencies
============ ============
* ``point_of_sale`` * ``point_of_sale``
* ``pos_hr``
* ``pos_employee_role`` * ``pos_employee_role``
* ``pos_restaurant``
* ``stock``
Installation Installation
============ ============

View File

@ -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. Only users with the 'Area Manager' or 'Store Manager' role can perform these actions.
""", """,
'author': "Suherdy Yacob", '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': [], 'data': [],
'assets': { 'assets': {
'point_of_sale._assets_pos': [ 'point_of_sale._assets_pos': [

View File

@ -1 +1,2 @@
from . import pos_order_line from . import pos_order_line
from . import stock_move

22
models/stock_move.py Normal file
View File

@ -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)

View File

@ -17,7 +17,7 @@ patch(PosOrder.prototype, {
return super.removeOrderline(...arguments); 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); const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role);
// Use the same helper as the line patch // Use the same helper as the line patch

View File

@ -41,7 +41,7 @@ patch(PosOrderline.prototype, {
return super.setQuantity(...arguments); 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 isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role);
const lockedQty = this.get_locked_qty(); const lockedQty = this.get_locked_qty();

View File

@ -3,7 +3,7 @@ import { patch } from "@web/core/utils/patch";
patch(TicketScreen.prototype, { patch(TicketScreen.prototype, {
shouldHideDeleteButton(order) { 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); const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role);
if (!isManager) { if (!isManager) {
return true; return true;

View File

@ -5,6 +5,14 @@ import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { transferState } from "../transfer_state"; import { transferState } from "../transfer_state";
patch(PosStore.prototype, { patch(PosStore.prototype, {
async processServerData() {
const res = await super.processServerData(...arguments);
if (this.models) {
this.models.pos = this;
}
return res;
},
async beforeDeleteOrder(order, options = {}) { async beforeDeleteOrder(order, options = {}) {
const cashier = this.getCashier(); const cashier = this.getCashier();
const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role); const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role);