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.
""",
'author': "Suherdy Yacob",
'depends': ['point_of_sale', 'pos_hr', 'pos_employee_role'],
'depends': ['point_of_sale', 'pos_hr', 'pos_employee_role', 'pos_restaurant'],
'data': [],
'assets': {
'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):
_inherit = 'pos.order.line'
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 { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
import { transferState } from "../transfer_state";
patch(PosOrder.prototype, {
updateLastOrderChange() {
@ -12,6 +13,10 @@ patch(PosOrder.prototype, {
},
removeOrderline(line) {
if (transferState.isTransferring) {
return super.removeOrderline(...arguments);
}
const cashier = this.models["pos.session"].getFirst()?.cashier;
const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role);
@ -31,3 +36,4 @@ patch(PosOrder.prototype, {
return super.removeOrderline(...arguments);
},
});

View File

@ -1,27 +1,46 @@
import { PosOrderline } from "@point_of_sale/app/models/pos_order_line";
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
import { transferState } from "../transfer_state";
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) {
super.init_from_JSON(...arguments);
this.x_locked_qty = json.x_locked_qty || 0;
this.x_transferred_qty = json.x_transferred_qty || 0;
},
export_as_JSON() {
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;
},
merge(orderline) {
super.merge(...arguments);
this.x_transferred_qty = (this.x_transferred_qty || 0) + (orderline.x_transferred_qty || 0);
},
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 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) {
if (transferState.isTransferring) {
return super.setQuantity(...arguments);
}
const cashier = this.models["pos.session"].getFirst()?.cashier;
const isManager = cashier && ['area_manager', 'outlet_manager'].includes(cashier.pos_role);
@ -46,3 +65,4 @@ patch(PosOrderline.prototype, {
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 { _t } from "@web/core/l10n/translation";
import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { transferState } from "../transfer_state";
patch(PosStore.prototype, {
async beforeDeleteOrder(order, options = {}) {
const cashier = this.getCashier();
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) {
for (const line of order.lines) {
const lockedQty = line.get_locked_qty ? line.get_locked_qty() : 0;
if (lockedQty > 0) {
this.dialog.add(AlertDialog, {
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;
}
@ -23,5 +24,49 @@ patch(PosStore.prototype, {
}
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,
};