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.
* **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
============

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.
""",
'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': [

View File

@ -1 +1,2 @@
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);
}
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

View File

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

View File

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

View File

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