From 8cf553e8ad9927a56f1275697f43bcecfe88e8fc Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Mon, 25 May 2026 17:57:53 +0700 Subject: [PATCH] feat: log POS order cancellations and line quantity reductions to chatter with employee details --- README.rst | 9 ++- models/__init__.py | 2 + models/pos_order.py | 77 +++++++++++++++++++++++++ static/src/app/models/pos_order.js | 51 +++++++++++++--- static/src/app/models/pos_order_line.js | 49 +++++++++++----- static/src/app/services/pos_store.js | 23 +++++++- 6 files changed, 185 insertions(+), 26 deletions(-) create mode 100644 models/pos_order.py 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:" + 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, { } } }); -