First Commit

This commit is contained in:
Suherdy Yacob 2026-05-07 15:33:20 +07:00
commit 08c4e3711b
9 changed files with 186 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
*$py.class
.DS_Store
*.swp
*.swo

36
README.rst Normal file
View File

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

1
__init__.py Normal file
View File

@ -0,0 +1 @@
from . import models

22
__manifest__.py Normal file
View File

@ -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',
}

1
models/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import pos_order_line

6
models/pos_order_line.py Normal file
View File

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

View File

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

View File

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

View File

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