feat: implement order transfer protection and restrict modifications for sent items

This commit is contained in:
Suherdy Yacob 2026-05-21 19:45:15 +07:00
parent c37db8da68
commit f25a550871
8 changed files with 126 additions and 7 deletions

View File

@ -9,7 +9,7 @@ This module prevents cashiers from deleting or reducing the quantity of products
Only users with the 'Area Manager' or 'Store Manager' role can perform these actions. Only users with the 'Area Manager' or 'Store Manager' role can perform these actions.
""", """,
'author': "Suherdy Yacob", 'author': "Suherdy Yacob",
'depends': ['point_of_sale', 'pos_hr', 'pos_employee_role'], 'depends': ['point_of_sale', 'pos_hr', 'pos_employee_role', 'pos_restaurant'],
'data': [], 'data': [],
'assets': { 'assets': {
'point_of_sale._assets_pos': [ 'point_of_sale._assets_pos': [

View File

@ -1,6 +1,17 @@
from odoo import models, fields from odoo import api, fields, models
class PosOrderLine(models.Model): class PosOrderLine(models.Model):
_inherit = 'pos.order.line' _inherit = 'pos.order.line'
x_locked_qty = fields.Float(string='Locked Quantity', default=0.0) x_locked_qty = fields.Float(string='Locked Quantity', default=0.0)
x_transferred_qty = fields.Float(string='Transferred Quantity', default=0.0)
@api.model
def _load_pos_data_fields(self, config):
fields = super()._load_pos_data_fields(config)
if 'x_locked_qty' not in fields:
fields.append('x_locked_qty')
if 'x_transferred_qty' not in fields:
fields.append('x_transferred_qty')
return fields

View File

@ -0,0 +1,21 @@
import { ClosePosPopup } from "@point_of_sale/app/components/popups/closing_popup/closing_popup";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
patch(ClosePosPopup.prototype, {
async handleClosingError(response) {
this.dialog.add(ConfirmationDialog, {
title: response.title || "Error",
body: response.message,
confirmLabel: _t("Review Orders"),
confirm: () => {
if (!response.redirect) {
this.props.close();
this.pos.navigate("TicketScreen");
}
},
dismiss: async () => {},
});
}
});

View File

@ -1,6 +1,7 @@
import { PosOrder } from "@point_of_sale/app/models/pos_order"; import { PosOrder } from "@point_of_sale/app/models/pos_order";
import { patch } from "@web/core/utils/patch"; import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation"; import { _t } from "@web/core/l10n/translation";
import { transferState } from "../transfer_state";
patch(PosOrder.prototype, { patch(PosOrder.prototype, {
updateLastOrderChange() { updateLastOrderChange() {
@ -12,6 +13,10 @@ patch(PosOrder.prototype, {
}, },
removeOrderline(line) { removeOrderline(line) {
if (transferState.isTransferring) {
return super.removeOrderline(...arguments);
}
const cashier = this.models["pos.session"].getFirst()?.cashier; const cashier = this.models["pos.session"].getFirst()?.cashier;
const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role); const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role);
@ -31,3 +36,4 @@ patch(PosOrder.prototype, {
return super.removeOrderline(...arguments); return super.removeOrderline(...arguments);
}, },
}); });

View File

@ -1,27 +1,46 @@
import { PosOrderline } from "@point_of_sale/app/models/pos_order_line"; import { PosOrderline } from "@point_of_sale/app/models/pos_order_line";
import { patch } from "@web/core/utils/patch"; import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation"; import { _t } from "@web/core/l10n/translation";
import { transferState } from "../transfer_state";
patch(PosOrderline.prototype, { patch(PosOrderline.prototype, {
setup(vals) {
super.setup(...arguments);
this.x_transferred_qty = vals.x_transferred_qty || 0;
this.x_locked_qty = vals.x_locked_qty || 0;
},
init_from_JSON(json) { init_from_JSON(json) {
super.init_from_JSON(...arguments); super.init_from_JSON(...arguments);
this.x_locked_qty = json.x_locked_qty || 0; this.x_locked_qty = json.x_locked_qty || 0;
this.x_transferred_qty = json.x_transferred_qty || 0;
}, },
export_as_JSON() { export_as_JSON() {
const json = super.export_as_JSON(...arguments); const json = super.export_as_JSON(...arguments);
json.x_locked_qty = this.get_locked_qty(); json.x_locked_qty = this.x_locked_qty || 0;
json.x_transferred_qty = this.x_transferred_qty || 0;
return json; return json;
}, },
merge(orderline) {
super.merge(...arguments);
this.x_transferred_qty = (this.x_transferred_qty || 0) + (orderline.x_transferred_qty || 0);
},
get_locked_qty() { get_locked_qty() {
// Use mp_qty (native Odoo sent qty) or our persisted x_locked_qty // Use mp_qty (native Odoo sent qty), our persisted x_locked_qty, or x_transferred_qty
const mpQty = parseFloat(this.mp_qty || 0); const mpQty = parseFloat(this.mp_qty || 0);
const xLockedQty = parseFloat(this.x_locked_qty || 0); const xLockedQty = parseFloat(this.x_locked_qty || 0);
return Math.max(mpQty, xLockedQty); const xTransferredQty = parseFloat(this.x_transferred_qty || 0);
return Math.max(mpQty, xLockedQty, xTransferredQty);
}, },
setQuantity(quantity, keep_price) { setQuantity(quantity, keep_price) {
if (transferState.isTransferring) {
return super.setQuantity(...arguments);
}
const cashier = this.models["pos.session"].getFirst()?.cashier; const cashier = this.models["pos.session"].getFirst()?.cashier;
const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role); const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role);
@ -46,3 +65,4 @@ patch(PosOrderline.prototype, {
return super.setQuantity(...arguments); return super.setQuantity(...arguments);
}, },
}); });

View File

@ -0,0 +1,13 @@
import { TicketScreen } from "@point_of_sale/app/screens/ticket_screen/ticket_screen";
import { patch } from "@web/core/utils/patch";
patch(TicketScreen.prototype, {
shouldHideDeleteButton(order) {
const cashier = this.pos.models["pos.session"].getFirst()?.cashier;
const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role);
if (!isManager) {
return true;
}
return super.shouldHideDeleteButton(...arguments);
}
});

View File

@ -2,20 +2,21 @@ import { PosStore } from "@point_of_sale/app/services/pos_store";
import { patch } from "@web/core/utils/patch"; import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation"; import { _t } from "@web/core/l10n/translation";
import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { transferState } from "../transfer_state";
patch(PosStore.prototype, { patch(PosStore.prototype, {
async beforeDeleteOrder(order, options = {}) { async beforeDeleteOrder(order, options = {}) {
const cashier = this.getCashier(); const cashier = this.getCashier();
const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role); const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role);
// Check if the order has any locked items (sent to kitchen) // Check if the order has any locked items (sent to kitchen or transferred)
if (!isManager && order && order.lines) { if (!isManager && order && order.lines) {
for (const line of order.lines) { for (const line of order.lines) {
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) { if (lockedQty > 0) {
this.dialog.add(AlertDialog, { this.dialog.add(AlertDialog, {
title: _t("Action Restricted"), title: _t("Action Restricted"),
body: _t("You cannot delete this order because some items have already been sent to the kitchen. Please call an Area Manager or Store Manager."), body: _t("You cannot delete this order because some items have already been sent to the kitchen or transferred. Please call an Area Manager or Store Manager."),
}); });
return false; return false;
} }
@ -23,5 +24,49 @@ patch(PosStore.prototype, {
} }
return super.beforeDeleteOrder(order, options); return super.beforeDeleteOrder(order, options);
},
prepareOrderTransfer(order, destinationTable) {
const originalTable = order.table_id;
const res = super.prepareOrderTransfer(...arguments);
if (res === false && originalTable && destinationTable && originalTable.id !== destinationTable.rootTable.id) {
// This is a transfer to an empty table!
for (const line of order.lines) {
line.x_transferred_qty = line.qty;
}
}
return res;
},
async mergeOrders(sourceOrder, destOrder) {
transferState.isTransferring = true;
try {
// Mark all source lines with x_transferred_qty = line.qty before merging
const sourceLinesMap = {};
for (const line of sourceOrder.lines) {
if (!line.x_transferred_qty) {
line.x_transferred_qty = line.qty;
}
sourceLinesMap[line.uuid] = line.x_transferred_qty;
}
const res = await super.mergeOrders(...arguments);
// Use the unmerge tracking to map transferred quantities to newly created destination lines
if (destOrder && destOrder.uiState && destOrder.uiState.unmerge) {
for (const [newUuid, unmergeInfo] of Object.entries(destOrder.uiState.unmerge)) {
const formerUuid = unmergeInfo.formerUuid;
const newLine = destOrder.lines.find(l => l.uuid === newUuid);
if (newLine && formerUuid && sourceLinesMap[formerUuid]) {
newLine.x_transferred_qty = sourceLinesMap[formerUuid];
}
}
}
return res;
} finally {
transferState.isTransferring = false;
}
} }
}); });

View File

@ -0,0 +1,3 @@
export const transferState = {
isTransferring: false,
};