feat: log POS order cancellations and line quantity reductions to chatter with employee details

This commit is contained in:
Suherdy Yacob 2026-05-25 17:57:53 +07:00
parent 508d9f7d87
commit 8cf553e8ad
6 changed files with 185 additions and 26 deletions

View File

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

View File

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

77
models/pos_order.py Normal file
View File

@ -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 = "<strong>Product Cancellation/Reduction Log:</strong><ul>"
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"<li><strong>{cancel.get('product_name')}</strong>: {action_str} by <strong>{cancel.get('employee_name', 'Unknown')}</strong></li>"
body += "</ul>"
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"<strong>Order Cancelled</strong> by <strong>{employee_name}</strong>")
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("<br/>") + f"{role_name} {self.employee_id.name}"
return super()._prepare_pos_log(body)

View File

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

View File

@ -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."),
};
// 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'
});
}
}
}
}
return super.setQuantity(...arguments);
},
});

View File

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