From e25d6f969f1658ca0644e30b170929d7f26f89d0 Mon Sep 17 00:00:00 2001 From: Risvana Cybro Date: Fri, 10 Oct 2025 11:36:52 +0530 Subject: [PATCH] Oct 10: [FIX] Bug fixed 'pos_auto_lot_selection' --- pos_auto_lot_selection/models/__init__.py | 1 + pos_auto_lot_selection/models/pos_order.py | 66 +++++++++ pos_auto_lot_selection/models/stock_lot.py | 109 +++++++++++---- .../static/src/js/product.js | 128 +++++++++++++++--- 4 files changed, 263 insertions(+), 41 deletions(-) create mode 100644 pos_auto_lot_selection/models/pos_order.py diff --git a/pos_auto_lot_selection/models/__init__.py b/pos_auto_lot_selection/models/__init__.py index 50d2c97ba..25f1c2aa4 100644 --- a/pos_auto_lot_selection/models/__init__.py +++ b/pos_auto_lot_selection/models/__init__.py @@ -20,3 +20,4 @@ # ############################################################################### from . import stock_lot +from . import pos_order diff --git a/pos_auto_lot_selection/models/pos_order.py b/pos_auto_lot_selection/models/pos_order.py new file mode 100644 index 000000000..7541213a5 --- /dev/null +++ b/pos_auto_lot_selection/models/pos_order.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +from odoo import api, models + +class PosOrder(models.Model): + _inherit = 'pos.order' + + @api.model + def _process_order(self, order, draft, existing_order): + """ + Ensure lots are properly updated after POS order validation. + """ + # Call the original method + pos_order_id = super(PosOrder, self)._process_order(order, draft, existing_order) + + # Make sure we have the record, not just an ID + pos_order = pos_order_id + if isinstance(pos_order_id, int): + pos_order = self.browse(pos_order_id) + + # Now safely loop through order lines + for order_line in pos_order.lines: + if order_line.pack_lot_ids: + self._update_lot_stock(order_line) + + return pos_order_id + + def _update_lot_stock(self, order_line): + """Deduct quantities from the correct lots when POS order is validated""" + product = order_line.product_id + pos_config = order_line.order_id.session_id.config_id + location_id = pos_config.picking_type_id.default_location_src_id.id + + for pack_lot in order_line.pack_lot_ids: + lot = self.env['stock.lot'].search([ + ('name', '=', pack_lot.lot_name), + ('product_id', '=', product.id) + ], limit=1) + + if not lot: + continue + + # Get all quants for this lot at POS location + quants = self.env['stock.quant'].sudo().search([ + ('product_id', '=', product.id), + ('lot_id', '=', lot.id), + ('location_id', '=', location_id) + ]) + if not quants: + continue + + qty_to_deduct = abs(order_line.qty) # ensure positive qty + + for quant in quants: + if qty_to_deduct <= 0: + break + + if quant.quantity >= qty_to_deduct: + quant.quantity -= qty_to_deduct + qty_to_deduct = 0 + else: + qty_to_deduct -= quant.quantity + quant.quantity = 0 + + # Optional: mark as taken if fully used + remaining_qty = sum(quants.mapped('quantity')) + lot.is_taken = remaining_qty <= 0 diff --git a/pos_auto_lot_selection/models/stock_lot.py b/pos_auto_lot_selection/models/stock_lot.py index d97a5b98b..44498daca 100644 --- a/pos_auto_lot_selection/models/stock_lot.py +++ b/pos_auto_lot_selection/models/stock_lot.py @@ -30,30 +30,89 @@ class StockLot(models.Model): help='If enables this lot number is taken') @api.model - def get_available_lots_for_pos(self, product_id): - """Get available lots for a product suitable for the Point of Sale - (PoS).This method retrieves the available lots for a specific product - that are suitable for the Point of Sale (PoS) based on the configured - removal strategy. The lots are sorted based on the expiration date or - creation date,depending on the removal strategy.""" + def check_product_stock_in_location(self, product_id, location_id): + """Check if product has positive stock in the given location""" + product = self.env['product.product'].browse(product_id) + + # Get on hand quantity using Odoo's built-in method + stock_quant = self.env['stock.quant'].search([ + ('product_id', '=', product_id), + ('location_id', '=', location_id) + ]) + + total_qty = sum(stock_quant.mapped('quantity')) + + return { + 'has_positive_stock': total_qty > 0, + 'total_quantity': total_qty + } + + @api.model + def get_available_lots_for_pos(self, product_id, pos_config_id=None): + """Get available lots for a product in POS location""" + + # Get POS location + if pos_config_id: + pos_config = self.env['pos.config'].browse(pos_config_id) + location_id = pos_config.picking_type_id.default_location_src_id.id + else: + pos_session = self.env['pos.session'].search([ + ('state', '=', 'opened'), + ('user_id', '=', self.env.uid) + ], limit=1) + if pos_session: + location_id = pos_session.config_id.picking_type_id.default_location_src_id.id + else: + return {'has_positive_stock': False, 'lots': []} + + # Check total stock in location + stock_check = self.check_product_stock_in_location(product_id, location_id) + + # If no positive stock, return immediately + if not stock_check['has_positive_stock']: + return { + 'has_positive_stock': False, + 'total_quantity': stock_check['total_quantity'], + 'lots': [] + } + + # Stock is positive, get lots with FEFO/FIFO + product = self.env['product.product'].browse(product_id) company_id = self.env.company.id - removal_strategy_id = (self.env['product.template'].browse( - self.env['product.product'].browse(product_id).product_tmpl_id.id) - .categ_id.removal_strategy_id.method) - if removal_strategy_id == 'fefo': - lots = self.sudo().search( - ["&", ["product_id", "=", product_id],"&",["is_taken","=",False], - "|", ["company_id", "=", company_id], - ["company_id", "=", False]], - order='expiration_date asc') + removal_strategy = product.product_tmpl_id.categ_id.removal_strategy_id.method or 'fifo' + + # Get all lots for this product + lot_domain = [ + ('product_id', '=', product_id), + '|', ('company_id', '=', company_id), ('company_id', '=', False) + ] + + if removal_strategy == 'fefo': + lots = self.sudo().search(lot_domain, order='expiration_date asc') else: - lots = self.sudo().search( - ["&", ["product_id", "=", product_id], "|", - ["company_id", "=", company_id], - ["company_id", "=", False], ], - order='create_date asc') - lots = lots.filtered(lambda l: float_compare( - l.product_qty, 0, - precision_digits=l.product_uom_id.rounding) > 0)[:1] - lots.is_taken = True - return lots.mapped("name") + lots = self.sudo().search(lot_domain, order='create_date asc') + + # Get lots with positive quantity in the location + available_lots = [] + for lot in lots: + quants = self.env['stock.quant'].sudo().search([ + ('lot_id', '=', lot.id), + ('location_id', '=', location_id), + ('product_id', '=', product_id) + ]) + + lot_qty = sum(quants.mapped('quantity')) + + if lot_qty > 0: + available_lots.append({ + 'lot_name': lot.name, + 'lot_id': lot.id, + 'available_qty': lot_qty, + 'expiration_date': str(lot.expiration_date) if lot.expiration_date else False, + }) + + return { + 'has_positive_stock': True, + 'total_quantity': stock_check['total_quantity'], + 'lots': available_lots + } diff --git a/pos_auto_lot_selection/static/src/js/product.js b/pos_auto_lot_selection/static/src/js/product.js index fcfedb787..47d163d5a 100644 --- a/pos_auto_lot_selection/static/src/js/product.js +++ b/pos_auto_lot_selection/static/src/js/product.js @@ -1,20 +1,21 @@ /** @odoo-module **/ import { patch } from "@web/core/utils/patch"; -import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen"; import { Product } from "@point_of_sale/app/store/models"; -import { jsonrpc } from "@web/core/network/rpc_service"; +import { _t } from "@web/core/l10n/translation"; import { ComboConfiguratorPopup } from "@point_of_sale/app/store/combo_configurator_popup/combo_configurator_popup"; patch(Product.prototype, { async getAddProductOptions(code) { let price_extra = 0.0; - let draftPackLotLines, packLotLinesToEdit, attribute_value_ids; + let draftPackLotLines, packLotLinesToEdit, attribute_value_ids; let quantity = 1; let comboLines = []; let attribute_custom_values = {}; + if (code && this.pos.db.product_packaging_by_barcode[code.code]) { quantity = this.pos.db.product_packaging_by_barcode[code.code].qty; } + if (this.isConfigurable()) { const { confirmed, payload } = await this.openConfigurator({ initQuantity: quantity }); if (confirmed) { @@ -26,12 +27,14 @@ patch(Product.prototype, { return; } } - if (this.combo_ids.length) { + + if (this.combo_ids.length) { const { confirmed, payload } = await this.env.services.popup.add( ComboConfiguratorPopup, { product: this, keepBehind: true } ); if (!confirmed) { + return; } comboLines = payload; } @@ -46,35 +49,127 @@ patch(Product.prototype, { .find((line) => line.product.id === this.id) ?.getPackLotLinesToEdit()) || []; - // if the lot information exists in the barcode, we don't need to ask it from the user. + + // If lot from barcode if (code && code.type === "lot") { - // consider the old and new packlot lines const modifiedPackLotLines = Object.fromEntries( packLotLinesToEdit.filter((item) => item.id).map((item) => [item.id, item.text]) ); const newPackLotLines = [{ lot_name: code.code }]; draftPackLotLines = { modifiedPackLotLines, newPackLotLines }; } else { - let result = await this.env.services.orm.call( - "stock.lot", "get_available_lots_for_pos",[], {product_id: this.id}); - const modifiedPackLotLines = result[0]; - const newPackLotLines = result.map(item => ({ lot_name: result[0] })); - draftPackLotLines = { modifiedPackLotLines, newPackLotLines }; + // Check location stock and get lots + try { + const result = await this.env.services.orm.call( + "stock.lot", + "get_available_lots_for_pos", + [], + { + product_id: this.id, + pos_config_id: this.pos.config.id + } + ); + + console.log("========================================="); + console.log("Product:", this.display_name); + console.log("Has Positive Stock:", result.has_positive_stock); + console.log("Total Qty in Location:", result.total_quantity); + console.log("Available Lots:", result.lots); + console.log("========================================="); + + // DECISION: Auto-assign if positive stock, manual if not + if (result.has_positive_stock === true) { + console.log("→ POSITIVE STOCK - AUTO ASSIGNING LOT"); + + if (result.lots && result.lots.length > 0) { + // Calculate already used quantities + const usedLotQty = {}; + const orderLines = this.pos.selectedOrder.get_orderlines(); + + for (const line of orderLines) { + if (line.product.id === this.id && line.pack_lot_lines) { + for (const lotLine of line.pack_lot_lines) { + if (lotLine.lot_name) { + usedLotQty[lotLine.lot_name] = + (usedLotQty[lotLine.lot_name] || 0) + line.quantity; + } + } + } + } + + console.log("Used quantities:", usedLotQty); + + // Find first lot with remaining quantity + let selectedLot = null; + for (const lot of result.lots) { + const used = usedLotQty[lot.lot_name] || 0; + const remaining = lot.available_qty - used; + console.log(`Lot ${lot.lot_name}: available=${lot.available_qty}, used=${used}, remaining=${remaining}`); + + if (remaining > 0) { + selectedLot = lot; + break; + } + } + + if (selectedLot) { + console.log("✓ Selected lot:", selectedLot.lot_name); + draftPackLotLines = { + modifiedPackLotLines: {}, + newPackLotLines: [{ lot_name: selectedLot.lot_name }] + }; + } else { + console.log("✗ All lots exhausted in order"); + await this.env.services.popup.add('ErrorPopup', { + title: _t('No Available Stock'), + body: _t('All available lots have been added to the order.'), + }); + return; + } + } else { + console.log("✗ No lots found but stock is positive"); + // Continue without assigning lot - will be handled by standard flow + draftPackLotLines = { + modifiedPackLotLines: Object.fromEntries( + packLotLinesToEdit.filter((item) => item.id).map((item) => [item.id, item.text]) + ), + newPackLotLines: [] + }; + } + } else { + console.log("→ NO POSITIVE STOCK - ADDING PRODUCT WITHOUT LOT"); + console.log("User can add lot manually from orderline"); + // Add product without lot - user can edit from orderline + draftPackLotLines = { + modifiedPackLotLines: Object.fromEntries( + packLotLinesToEdit.filter((item) => item.id).map((item) => [item.id, item.text]) + ), + newPackLotLines: [] + }; + } + } catch (error) { + console.error("ERROR:", error); + // Fallback - add without lot + draftPackLotLines = { + modifiedPackLotLines: Object.fromEntries( + packLotLinesToEdit.filter((item) => item.id).map((item) => [item.id, item.text]) + ), + newPackLotLines: [] + }; + } } + if (!draftPackLotLines) { return; } } + // Take the weight if necessary. if (this.to_weight && this.pos.config.iface_electronic_scale) { - // Show the ScaleScreen to weigh the product. if (this.isScaleAvailable) { - const product = this; const { confirmed, payload } = await this.env.services.pos.showTempScreen( "ScaleScreen", - { - product, - } + { product: this } ); if (confirmed) { quantity = payload.weight; @@ -85,6 +180,7 @@ patch(Product.prototype, { await this._onScaleNotAvailable(); } } + return { draftPackLotLines, quantity,