diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 0ee918a704d9d..3c53eb4026ec3 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -1388,7 +1388,7 @@ def get_existing_lots(self, company_id, product_id): filtered(lambda q: float_compare(q.quantity, 0, precision_rounding=q.product_id.uom_id.rounding) > 0).\ mapped('lot_id') - return available_lots.read(['id', 'name']) + return available_lots.read(['id', 'name', 'product_qty']) @api.ondelete(at_uninstall=False) def _unlink_except_order_state(self): diff --git a/addons/point_of_sale/static/src/app/store/pos_store.js b/addons/point_of_sale/static/src/app/store/pos_store.js index af841b95ffb66..c8ede31c4d41a 100644 --- a/addons/point_of_sale/static/src/app/store/pos_store.js +++ b/addons/point_of_sale/static/src/app/store/pos_store.js @@ -1884,6 +1884,38 @@ export class PosStore extends Reactive { canCreateLots = true; } + const usedLotsQty = this.models["pos.pack.operation.lot"] + .filter( + (lot) => + lot.pos_order_line_id?.product_id?.id === product.id && + lot.pos_order_line_id?.order_id?.state === "draft" + ) + .reduce((acc, lot) => { + if (!acc[lot.lot_name]) { + acc[lot.lot_name] = { total: 0, currentOrderCount: 0 }; + } + acc[lot.lot_name].total += lot.pos_order_line_id?.qty || 0; + + if (lot.pos_order_line_id?.order_id?.id === this.selectedOrder.id) { + acc[lot.lot_name].currentOrderCount += lot.pos_order_line_id?.qty || 0; + } + return acc; + }, {}); + + // Remove lot/serial names that are already used in draft orders + existingLots = existingLots.filter((lot) => { + return lot.product_qty > (usedLotsQty[lot.name]?.total || 0); + }); + + // Check if the input lot/serial name is already used in another order + const isLotNameUsed = (itemValue) => { + const totalQty = existingLots.find((lt) => lt.name == itemValue)?.product_qty || 0; + const usedQty = usedLotsQty[itemValue] + ? usedLotsQty[itemValue].total - usedLotsQty[itemValue].currentOrderCount + : 0; + return usedQty ? usedQty >= totalQty : false; + }; + const existingLotsName = existingLots.map((l) => l.name); const payload = await makeAwaitable(this.dialog, EditListPopup, { title: _t("Lot/Serial Number(s) Required"), @@ -1893,6 +1925,7 @@ export class PosStore extends Reactive { options: existingLotsName, customInput: canCreateLots, uniqueValues: product.tracking === "serial", + isLotNameUsed: isLotNameUsed, }); if (payload) { // Segregate the old and new packlot lines diff --git a/addons/point_of_sale/static/src/app/store/select_lot_popup/select_lot_popup.js b/addons/point_of_sale/static/src/app/store/select_lot_popup/select_lot_popup.js index 3ece3cb4fb2db..d3270b6ba2952 100644 --- a/addons/point_of_sale/static/src/app/store/select_lot_popup/select_lot_popup.js +++ b/addons/point_of_sale/static/src/app/store/select_lot_popup/select_lot_popup.js @@ -45,11 +45,13 @@ export class EditListPopup extends Component { options: { type: Array, optional: true }, customInput: { type: Boolean, optional: true }, uniqueValues: { type: Boolean, optional: true }, + isLotNameUsed: { type: Function, optional: true }, }; static defaultProps = { options: [], customInput: true, uniqueValues: true, + isLotNameUsed: () => false, }; /** @@ -121,6 +123,7 @@ export class EditListPopup extends Component { } hasValidValue(itemId, text) { return ( + !this.props.isLotNameUsed(text) && (this.props.customInput || this.props.options.includes(text)) && (!this.props.uniqueValues || !this.state.array.some((elem) => elem._id !== itemId && elem.text === text)) @@ -186,6 +189,7 @@ export class EditListPopup extends Component { const itemValue = item.text.trim(); const isValidValue = itemValue !== "" && + !this.props.isLotNameUsed(itemValue) && (this.props.customInput || this.props.options.includes(itemValue)); if (!isValidValue) { return false; diff --git a/addons/point_of_sale/static/tests/tours/ticket_screen_tour.js b/addons/point_of_sale/static/tests/tours/ticket_screen_tour.js index e22a5649651f9..094f1138a6755 100644 --- a/addons/point_of_sale/static/tests/tours/ticket_screen_tour.js +++ b/addons/point_of_sale/static/tests/tours/ticket_screen_tour.js @@ -269,5 +269,13 @@ registry.category("web_tour.tours").add("LotTour", { inLeftSide({ trigger: ".info-list:contains('SN 3')", }), + + // Verify if the serial number can be reused for the current order + Chrome.createFloatingOrder(), + ProductScreen.clickDisplayedProduct("Product A"), + ProductScreen.enterLastLotNumber("3"), + inLeftSide({ + trigger: ".info-list:not(:contains('SN 3'))", + }), ].flat(), });