diff --git a/README.rst b/README.rst
index 0bdec08..d0f64e9 100644
--- a/README.rst
+++ b/README.rst
@@ -12,17 +12,20 @@ Features
* **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.
+* **Chatter Cancellation Logs**: Structurally logs all supervisor-approved sent product deletions, quantity reductions, and entire order cancellations directly to the backend order's chatter thread.
+* **Role-Aware Logging**: Overrides Odoo's native hardcoded "Cashier [Name]" log format to dynamically resolve and print the employee's actual POS Role (e.g. `Outlet Manager Suherdy Yacob` or `Area Manager Suherdy Yacob`), keeping tracking records fully accurate.
Technical Details
=================
* **Models Patched**:
+ * ``pos.order``: Added ``x_logged_cancellations`` text field for offline-resilient, deduplicated chatter logging. Intercepts incoming order syncing via ``_process_order`` to write structured HTML cancellation logs to chatter. Overrides Odoo's native ``_prepare_pos_log`` to display dynamic POS roles in place of hardcoded "Cashier" strings.
* ``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``.
+ * ``PosStore``: Binds the active ``PosStore`` service globally, enforces cashier role bypass checks, and patches ``deleteOrders`` to inject the cashier's employee ID into the context of backend order cancellation RPC calls.
+ * ``PosOrderline``: Enforces quantity checks, and logs supervisor-approved quantity reductions under ``x_cancelled_lines``.
+ * ``PosOrder``: Tracks supervisor-approved full kitchen item deletions under ``x_cancelled_lines``, and serializes this logging queue to the backend.
* ``TicketScreen``: Intercepts the UI delete/refund actions using ``getCashier()`` to dynamically enforce supervisor authorization.
Dependencies
diff --git a/models/__init__.py b/models/__init__.py
index d28b120..a66cbc5 100644
--- a/models/__init__.py
+++ b/models/__init__.py
@@ -1,2 +1,4 @@
from . import pos_order_line
from . import stock_move
+from . import pos_order
+
diff --git a/models/pos_order.py b/models/pos_order.py
new file mode 100644
index 0000000..1c461a6
--- /dev/null
+++ b/models/pos_order.py
@@ -0,0 +1,77 @@
+import json
+import logging
+from odoo import api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+class PosOrder(models.Model):
+ _inherit = 'pos.order'
+
+ x_logged_cancellations = fields.Text(string='Logged Cancellations', default='[]')
+
+ @api.model
+ def _process_order(self, order, existing_order):
+ # Extract frontend cancelled lines log
+ cancelled_lines = order.get('x_cancelled_lines', [])
+
+ res = super()._process_order(order, existing_order)
+
+ pos_order = self.browse(res) if isinstance(res, int) else res
+
+ if pos_order and cancelled_lines:
+ pos_order._log_cancelled_lines_to_chatter(cancelled_lines)
+
+ return res
+
+ def _log_cancelled_lines_to_chatter(self, cancelled_lines):
+ self.ensure_one()
+ try:
+ logged_ids = json.loads(self.x_logged_cancellations or '[]')
+ except Exception:
+ logged_ids = []
+
+ new_cancellations = []
+ for cancel in cancelled_lines:
+ cancel_id = cancel.get('id')
+ if cancel_id and cancel_id not in logged_ids:
+ new_cancellations.append(cancel)
+ logged_ids.append(cancel_id)
+
+ if new_cancellations:
+ body = "Product Cancellation/Reduction Log:
"
+ for cancel in new_cancellations:
+ if cancel.get('action') == 'delete':
+ action_str = f"Deleted completely (quantity was {cancel.get('qty', 0)})"
+ else:
+ action_str = f"Reduced quantity by {cancel.get('cancelled_qty', 0)} (from {cancel.get('qty', 0)} to {float(cancel.get('qty', 0)) - float(cancel.get('cancelled_qty', 0))})"
+ body += f"- {cancel.get('product_name')}: {action_str} by {cancel.get('employee_name', 'Unknown')}
"
+ body += "
"
+ self.message_post(body=body)
+ self.write({'x_logged_cancellations': json.dumps(logged_ids)})
+
+ def action_pos_order_cancel(self):
+ # Capture the cashier / employee who cancelled the order
+ employee_id = self.env.context.get('cancelled_by_employee_id')
+ employee_name = "Unknown Employee"
+ if employee_id:
+ employee = self.env['hr.employee'].browse(employee_id)
+ if employee.exists():
+ employee_name = employee.name
+ else:
+ employee_name = self.env.user.name
+
+ res = super().action_pos_order_cancel()
+
+ for order in self:
+ order.message_post(body=f"Order Cancelled by {employee_name}")
+
+ return res
+
+ def _prepare_pos_log(self, body):
+ if self.employee_id and hasattr(self.employee_id, 'pos_role') and self.employee_id.pos_role:
+ role_selection = dict(self.env['hr.employee']._fields['pos_role'].selection)
+ role_name = role_selection.get(self.employee_id.pos_role, "Cashier")
+ from markupsafe import Markup
+ return body + Markup("
") + f"{role_name} {self.employee_id.name}"
+ return super()._prepare_pos_log(body)
+
diff --git a/static/src/app/models/pos_order.js b/static/src/app/models/pos_order.js
index 1aca9ac..fcc0825 100644
--- a/static/src/app/models/pos_order.js
+++ b/static/src/app/models/pos_order.js
@@ -4,6 +4,22 @@ import { _t } from "@web/core/l10n/translation";
import { transferState } from "../transfer_state";
patch(PosOrder.prototype, {
+ setup() {
+ super.setup(...arguments);
+ this.x_cancelled_lines = this.x_cancelled_lines || [];
+ },
+
+ init_from_JSON(json) {
+ super.init_from_JSON(...arguments);
+ this.x_cancelled_lines = json.x_cancelled_lines || [];
+ },
+
+ export_as_JSON() {
+ const json = super.export_as_JSON(...arguments);
+ json.x_cancelled_lines = this.x_cancelled_lines || [];
+ return json;
+ },
+
updateLastOrderChange() {
super.updateLastOrderChange(...arguments);
for (const line of this.lines) {
@@ -23,17 +39,34 @@ patch(PosOrder.prototype, {
// Use the same helper as the line patch
const lockedQty = line.get_locked_qty ? line.get_locked_qty() : 0;
- if (lockedQty > 0 && !isManager) {
- if (line.qty > lockedQty) {
- line.setQuantity(lockedQty);
- return false;
+ if (lockedQty > 0) {
+ if (!isManager) {
+ 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 or Store Manager."),
+ };
+ } else {
+ // Recorded cancellation log for the deletion
+ this.x_cancelled_lines = this.x_cancelled_lines || [];
+ const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+ var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
+ return v.toString(16);
+ });
+ this.x_cancelled_lines.push({
+ id: uuid,
+ product_name: line.product_id.display_name,
+ qty: line.qty,
+ cancelled_qty: line.qty,
+ employee_name: cashier ? cashier.name : 'Unknown',
+ employee_id: cashier ? cashier.id : null,
+ action: 'delete'
+ });
}
- 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 or Store 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
index d3f7794..2ae59f6 100644
--- a/static/src/app/models/pos_order_line.js
+++ b/static/src/app/models/pos_order_line.js
@@ -47,22 +47,45 @@ patch(PosOrderline.prototype, {
const lockedQty = this.get_locked_qty();
const newQty = (typeof quantity === "number" || (typeof quantity === "string" && quantity !== "")) ? parseFloat(quantity) : 0;
- if (lockedQty > 0 && !isManager) {
- // 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);
+ if (lockedQty > 0) {
+ if (!isManager) {
+ // 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 or Store Manager."),
+ };
+ }
+ } else {
+ // Manager is reducing quantity!
+ if (quantity === "" || isNaN(parseFloat(quantity)) || newQty < lockedQty) {
+ const targetQty = isNaN(newQty) ? 0 : newQty;
+ const diff = this.qty - targetQty;
+ if (diff > 0) {
+ this.order_id.x_cancelled_lines = this.order_id.x_cancelled_lines || [];
+ const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+ var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
+ return v.toString(16);
+ });
+ this.order_id.x_cancelled_lines.push({
+ id: uuid,
+ product_name: this.product_id.display_name,
+ qty: this.qty,
+ cancelled_qty: diff,
+ employee_name: cashier ? cashier.name : 'Unknown',
+ employee_id: cashier ? cashier.id : null,
+ action: 'reduce'
+ });
+ }
}
-
- // 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 or Store Manager."),
- };
}
}
return super.setQuantity(...arguments);
},
});
-
diff --git a/static/src/app/services/pos_store.js b/static/src/app/services/pos_store.js
index 2bfbe0e..633bcea 100644
--- a/static/src/app/services/pos_store.js
+++ b/static/src/app/services/pos_store.js
@@ -34,6 +34,28 @@ patch(PosStore.prototype, {
return super.beforeDeleteOrder(order, options);
},
+ async deleteOrders(orders, serverIds = [], ignoreChange = false) {
+ const cashier = this.getCashier();
+ const oldCall = this.data.call;
+ if (cashier) {
+ this.data.call = async function(model, method, args, kwargs) {
+ if (model === "pos.order" && method === "action_pos_order_cancel") {
+ kwargs = kwargs || {};
+ kwargs.context = {
+ ...(kwargs.context || {}),
+ cancelled_by_employee_id: cashier.id
+ };
+ }
+ return oldCall.apply(this, arguments);
+ };
+ }
+ try {
+ return await super.deleteOrders(...arguments);
+ } finally {
+ this.data.call = oldCall;
+ }
+ },
+
prepareOrderTransfer(order, destinationTable) {
const originalTable = order.table_id;
const res = super.prepareOrderTransfer(...arguments);
@@ -77,4 +99,3 @@ patch(PosStore.prototype, {
}
}
});
-