feat: log POS order cancellations and line quantity reductions to chatter with employee details
This commit is contained in:
parent
508d9f7d87
commit
8cf553e8ad
@ -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.
|
* **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.
|
* **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.
|
* **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
|
Technical Details
|
||||||
=================
|
=================
|
||||||
|
|
||||||
* **Models Patched**:
|
* **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.
|
* ``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.
|
* ``stock.move``: Overrides ``write`` to separate writes on cancelled and active moves, avoiding stock validation exceptions when managers cancel sent quantities.
|
||||||
* **Javascript Patches**:
|
* **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.
|
* ``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 in ``setQuantity`` using both Odoo's native ``mp_qty`` and the custom ``x_locked_qty``.
|
* ``PosOrderline``: Enforces quantity checks, and logs supervisor-approved quantity reductions under ``x_cancelled_lines``.
|
||||||
* ``PosOrder``: Intercepts ``removeOrderline`` to block full line deletions and captures snapshots during ``updateLastOrderChange``.
|
* ``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.
|
* ``TicketScreen``: Intercepts the UI delete/refund actions using ``getCashier()`` to dynamically enforce supervisor authorization.
|
||||||
|
|
||||||
Dependencies
|
Dependencies
|
||||||
|
|||||||
@ -1,2 +1,4 @@
|
|||||||
from . import pos_order_line
|
from . import pos_order_line
|
||||||
from . import stock_move
|
from . import stock_move
|
||||||
|
from . import pos_order
|
||||||
|
|
||||||
|
|||||||
77
models/pos_order.py
Normal file
77
models/pos_order.py
Normal 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)
|
||||||
|
|
||||||
@ -4,6 +4,22 @@ import { _t } from "@web/core/l10n/translation";
|
|||||||
import { transferState } from "../transfer_state";
|
import { transferState } from "../transfer_state";
|
||||||
|
|
||||||
patch(PosOrder.prototype, {
|
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() {
|
updateLastOrderChange() {
|
||||||
super.updateLastOrderChange(...arguments);
|
super.updateLastOrderChange(...arguments);
|
||||||
for (const line of this.lines) {
|
for (const line of this.lines) {
|
||||||
@ -23,17 +39,34 @@ patch(PosOrder.prototype, {
|
|||||||
// Use the same helper as the line patch
|
// Use the same helper as the line patch
|
||||||
const lockedQty = line.get_locked_qty ? line.get_locked_qty() : 0;
|
const lockedQty = line.get_locked_qty ? line.get_locked_qty() : 0;
|
||||||
|
|
||||||
if (lockedQty > 0 && !isManager) {
|
if (lockedQty > 0) {
|
||||||
if (line.qty > lockedQty) {
|
if (!isManager) {
|
||||||
line.setQuantity(lockedQty);
|
if (line.qty > lockedQty) {
|
||||||
return false;
|
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);
|
return super.removeOrderline(...arguments);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -47,22 +47,45 @@ patch(PosOrderline.prototype, {
|
|||||||
const lockedQty = this.get_locked_qty();
|
const lockedQty = this.get_locked_qty();
|
||||||
const newQty = (typeof quantity === "number" || (typeof quantity === "string" && quantity !== "")) ? parseFloat(quantity) : 0;
|
const newQty = (typeof quantity === "number" || (typeof quantity === "string" && quantity !== "")) ? parseFloat(quantity) : 0;
|
||||||
|
|
||||||
if (lockedQty > 0 && !isManager) {
|
if (lockedQty > 0) {
|
||||||
// If trying to delete or reduce below locked qty
|
if (!isManager) {
|
||||||
if (quantity === "" || isNaN(parseFloat(quantity)) || newQty < lockedQty) {
|
// If trying to delete or reduce below locked qty
|
||||||
// If there's unsent quantity, gracefully reduce it to the locked quantity
|
if (quantity === "" || isNaN(parseFloat(quantity)) || newQty < lockedQty) {
|
||||||
if (this.qty > lockedQty) {
|
// If there's unsent quantity, gracefully reduce it to the locked quantity
|
||||||
return super.setQuantity(lockedQty, keep_price);
|
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);
|
return super.setQuantity(...arguments);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,28 @@ patch(PosStore.prototype, {
|
|||||||
return super.beforeDeleteOrder(order, options);
|
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) {
|
prepareOrderTransfer(order, destinationTable) {
|
||||||
const originalTable = order.table_id;
|
const originalTable = order.table_id;
|
||||||
const res = super.prepareOrderTransfer(...arguments);
|
const res = super.prepareOrderTransfer(...arguments);
|
||||||
@ -77,4 +99,3 @@ patch(PosStore.prototype, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user