# Copyright 2019 Sergio Teruel # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import logging from odoo import _, api, fields, models _logger = logging.getLogger(__name__) TYPE_ERROR = ["more_match", "not_found"] class WizStockBarcodesRead(models.AbstractModel): _name = "wiz.stock.barcodes.read" _inherit = "barcodes.barcode_events_mixin" _description = "Wizard to read barcode" # To prevent remove the record wizard until 2 days old _transient_max_hours = 48 _allowed_product_types = ["product", "consu"] _rec_name = "barcode" barcode = fields.Char() res_model_id = fields.Many2one(comodel_name="ir.model", index=True) res_id = fields.Integer(index=True) product_id = fields.Many2one( comodel_name="product.product", domain=[("type", "in", _allowed_product_types)] ) product_uom_id = fields.Many2one(comodel_name="uom.uom") product_tracking = fields.Selection(related="product_id.tracking", readonly=True) lot_id = fields.Many2one(comodel_name="stock.lot") lot_name = fields.Char( "Lot/Serial Number Name", compute="_compute_lot_name", readonly=False, store=True, ) location_id = fields.Many2one(comodel_name="stock.location") location_dest_id = fields.Many2one( comodel_name="stock.location", string="Location dest." ) packaging_id = fields.Many2one(comodel_name="product.packaging") product_packaging_ids = fields.One2many(related="product_id.packaging_ids") package_id = fields.Many2one(comodel_name="stock.quant.package") result_package_id = fields.Many2one(comodel_name="stock.quant.package") owner_id = fields.Many2one(comodel_name="res.partner") packaging_qty = fields.Float(string="Package Qty", digits="Product Unit of Measure") product_qty = fields.Float(digits="Product Unit of Measure") manual_entry = fields.Boolean(string="Manual", help="Entry manual data") confirmed_moves = fields.Boolean( string="Confirmed moves", related="option_group_id.confirmed_moves" ) message_type = fields.Selection( [ ("info", "Barcode read with additional info"), ("info_page", "Info page"), ("not_found", "No barcode found"), ("more_match", "More than one matches found"), ("success", "Barcode read correctly"), ], readonly=True, ) message = fields.Char(readonly=True) message_step = fields.Char(readonly=True) guided_product_id = fields.Many2one(comodel_name="product.product") guided_location_id = fields.Many2one(comodel_name="stock.location") guided_location_dest_id = fields.Many2one(comodel_name="stock.location") guided_lot_id = fields.Many2one(comodel_name="stock.lot") action_ids = fields.Many2many( comodel_name="stock.barcodes.action", compute="_compute_action_ids" ) option_group_id = fields.Many2one(comodel_name="stock.barcodes.option.group") visible_force_done = fields.Boolean() step = fields.Integer() is_manual_qty = fields.Boolean(compute="_compute_is_manual_qty") is_manual_confirm = fields.Boolean(compute="_compute_is_manual_qty") # Technical field to allow use in attrs display_menu = fields.Boolean() auto_lot = fields.Boolean( string="Get lots automatically", help="If checked the lot will be set automatically with the same " "removal startegy", compute="_compute_auto_lot", store=True, readonly=False, ) create_lot = fields.Boolean( string="Allow create lot", help="Show lot name field", compute="_compute_create_lot", ) display_assign_serial = fields.Boolean(compute="_compute_display_assign_serial") keep_result_package = fields.Boolean() total_product_uom_qty = fields.Float( string="Product Demand", digits="Product Unit of Measure", store=False ) total_product_qty_done = fields.Float( string="Product Qty. Done", digits="Product Unit of Measure", store=False ) enable_add_product = fields.Boolean(default=True) @api.depends("res_id") def _compute_action_ids(self): actions = self.env["stock.barcodes.action"].search( [("action_window_id", "!=", False)] ) self.action_ids = actions @api.depends("option_group_id") def _compute_is_manual_qty(self): for rec in self: rec.is_manual_qty = rec.option_group_id.is_manual_qty rec.is_manual_confirm = rec.option_group_id.is_manual_confirm rec.auto_lot = rec.option_group_id.auto_lot @api.depends("option_group_id") def _compute_auto_lot(self): for rec in self: rec.auto_lot = rec.option_group_id.auto_lot @api.depends("option_group_id") def _compute_create_lot(self): for rec in self: rec.create_lot = rec.option_group_id.create_lot @api.depends("product_id") def _compute_display_assign_serial(self): for rec in self: rec.display_assign_serial = rec.product_id.tracking == "serial" @api.depends("lot_id") def _compute_lot_name(self): for rec in self: rec.lot_name = rec.lot_id.name @api.onchange("packaging_qty") def onchange_packaging_qty(self): if self.packaging_id: self.product_qty = self.packaging_qty * self.packaging_id.qty @api.onchange( "product_id", "lot_id", "package_id", "result_package_id", "packaging_qty", "product_qty", ) def onchange_visible_force_done(self): self.visible_force_done = False def _set_messagge_info(self, message_type, message): """ Set message type and message description. For manual entry mode barcode is not set so is not displayed """ self.message_type = message_type # if self.barcode and self.message_type in ["more_match", "not_found"]: if self.barcode: self.message = _( "%(barcode)s (%(message)s)", barcode=self.barcode, message=message ) else: if message_type in TYPE_ERROR: self.manual_entry = True self.send_bus_done( "stock_barcodes_scan", "actions_barcode_notification", { "message": message, "sticky": True, "message_type": "danger" if message_type in TYPE_ERROR else message_type, }, ) elif message_type != "info_page": self.send_bus_done( "stock_barcodes_scan", "actions_barcode_notification", { "message": message, "message_type": message_type, }, ) else: self.message = "%s" % message def process_barcode_location_id(self): location = self.env["stock.location"].search(self._barcode_domain(self.barcode)) if location: self.location_id = location return True return False def process_barcode_location_dest_id(self): location = self.env["stock.location"].search(self._barcode_domain(self.barcode)) if location: self.location_dest_id = location return True return False def process_barcode_product_id(self): domain = self._barcode_domain(self.barcode) product = self.env["product.product"].search(domain) if product: if len(product) > 1: self._set_messagge_info("more_match", _("More than one product found")) return False elif product.type not in self._allowed_product_types: self._set_messagge_info( "not_found", _("The product type is not allowed") ) return False self.action_product_scaned_post(product) if ( self.option_group_id.fill_fields_from_lot and self.location_id and self.product_id ): quant_domain = [ ("location_id", "=", self.location_id.id), ("product_id", "=", product.id), ] if self.lot_id: quant_domain.append(("lot_id", "=", self.lot_id.id)) if self.package_id: quant_domain.append(("package_id", "=", self.package_id.id)) if self.owner_id: quant_domain.append(("owner_id", "=", self.owner_id.id)) quants = self.env["stock.quant"].search(quant_domain) if quants: self.set_info_from_quants(quants) return True return False def process_barcode_lot_id(self): if self.env.user.has_group("stock.group_production_lot"): lot_domain = [("name", "=", self.barcode)] if self.product_id: lot_domain.append(("product_id", "=", self.product_id.id)) lot = self.env["stock.lot"].search(lot_domain) if len(lot) == 1: if self.option_group_id.fill_fields_from_lot: quant_domain = [ ("lot_id.name", "=", self.barcode), ("product_id", "=", lot.product_id.id), ("quantity", ">", 0.0), ] if self.location_id: quant_domain.append(("location_id", "=", self.location_id.id)) else: quant_domain.append(("location_id.usage", "=", "internal")) if self.owner_id: quant_domain.append(("owner_id", "=", self.owner_id.id)) quants = self.env["stock.quant"].search(quant_domain) if ( not self._name == "wiz.stock.barcodes.read.inventory" and not quants and not self.option_group_id.allow_negative_quant ): self._set_messagge_info( "more_match", _("No stock available for this lot with screen values"), ) self.lot_id = False self.lot_name = False return False if quants: self.set_info_from_quants(quants) else: self.product_id = lot.product_id self.action_lot_scaned_post(lot) return True else: self.product_id = lot.product_id self.action_lot_scaned_post(lot) return True elif lot: self._set_messagge_info( "more_match", _("More than one lot found\nScan product before") ) elif ( self.product_id and self.product_id.tracking != "none" and self.option_group_id.create_lot ): self.lot_name = self.barcode self.action_lot_scaned_post(self.lot_name) return True return False def process_barcode_package_id(self): if not self.env.user.has_group("stock.group_tracking_lot"): return False quant_domain = [ ("package_id.name", "=", self.barcode), ("quantity", ">", 0.0), ] if self.option_group_id.get_option_value("location_id", "forced"): quant_domain.append(("location_id", "=", self.location_id.id)) if self.owner_id: quant_domain.append(("owner_id", "=", self.owner_id.id)) quants = self.env["stock.quant"].search(quant_domain) internal_quants = quants.filtered(lambda q: q.location_id.usage == "internal") if internal_quants: quants = internal_quants elif quants: self = self.with_context(ignore_quant_location=True) # self._set_messagge_info("more_match", _("Package located external location")) else: # self._set_messagge_info("more_match", _("Package not fount or empty")) return False self.set_info_from_quants(quants) return True def process_barcode_result_package_id(self): if not self.env.user.has_group("stock.group_tracking_lot"): return False domain = [("name", "=", self.barcode)] package = self.env["stock.quant.package"].search(domain) if package: self.result_package_id = package[:1] return True return False def set_info_from_quants(self, quants): """ Fill wizard fields from stock quants """ if self.env.context.get("skip_set_info_from_quants"): return ignore_quant_location = self.env.context.get( "ignore_quant_location", self.option_group_id.ignore_quant_location ) if len(quants) == 1: # All ok self.action_product_scaned_post(quants.product_id) self.package_id = quants.package_id self.result_package_id = quants.package_id if quants.lot_id: self.action_lot_scaned_post(quants.lot_id) if quants.owner_id: self.owner_id = quants.owner_id # Review conditions if ( not ignore_quant_location and not self.option_group_id.get_option_value("location_id", "forced") and self.option_group_id.code != "IN" ): self.location_id = quants.location_id if self.option_group_id.code != "OUT" and not self.env.context.get( "skip_update_quantity_from_lot", False ): self.product_qty = quants.quantity elif len(quants) > 1: # More than one record found with same barcode. # Could be half lot in two distinct locations. # Empty location field to force a location barcode scan products = quants.mapped("product_id") if len(products) == 1: self.action_product_scaned_post(products[0]) package = quants[0].package_id if not quants.filtered(lambda q: q.package_id != package): self.package_id = package lots = quants.mapped("lot_id") if len(lots) == 1: self.action_lot_scaned_post(lots[0]) owner = quants[0].owner_id if not quants.filtered(lambda q: q.owner_id != owner): self.owner_id = owner if not ignore_quant_location: locations = quants.mapped("location_id") if len(locations) == 1: if not self.location_id and self.option_group_id.code != "IN": self.location_id = locations def process_barcode_packaging_id(self): domain = self._barcode_domain(self.barcode) if self.env.user.has_group("product.group_stock_packaging"): domain.append(("product_id", "!=", False)) packaging = self.env["product.packaging"].search(domain) if packaging: if len(packaging) > 1: self._set_messagge_info( "more_match", _("More than one package found") ) self.packaging_id = False return False self.action_packaging_scaned_post(packaging) return True return False def process_barcode(self, barcode): if not self: barcode_action = self.env["stock.barcodes.action"].search( [ ("action_window_id", "!=", False), ("barcode", "=", barcode), ], limit=1, ) self.env["bus.bus"]._sendone( "stock_barcodes_scan", "actions_main_menu_barcode", { "action_ok": len(barcode_action) > 0, "action": barcode_action.open_action() if barcode_action else "", "barcode": barcode, }, ) else: self._set_messagge_info("success", _("OK")) options = self.option_group_id.option_ids barcode_found = False options_to_scan = options.filtered("to_scan") options_required = options.filtered("required") options_to_scan = options_to_scan.filtered(lambda op: op.step == self.step) for option in options_to_scan: if ( self.option_group_id.ignore_filled_fields and option in options_required and getattr(self, option.field_name, False) ): continue option_func = getattr( self, "process_barcode_%s" % option.field_name, False ) if option_func: res = option_func() if res: barcode_found = True self.play_sounds(barcode_found) break elif self.message_type != "success": self.play_sounds(False) return False if not barcode_found: self.play_sounds(barcode_found) if self.option_group_id.ignore_filled_fields: self._set_messagge_info( "not_found", _("Barcode not found or field already filled") ) else: self._set_messagge_info( "not_found", _("Barcode not found with this screen values") ) self.display_notification( self.barcode, message_type="danger", title=_("Barcode not found"), sticky=False, ) return False if not self.check_option_required(): return False if self.is_manual_confirm or self.manual_entry: self._set_messagge_info("info", _("Review and confirm")) return False return self.action_confirm() def check_option_required(self): options = self.option_group_id.option_ids options_required = options.filtered("required") for option in options_required: if not getattr(self, option.field_name, False): if self.is_manual_qty and option.field_name in [ "product_qty", "packaging_qty", ]: self._set_focus_on_qty_input("product_qty") if option.field_name == "lot_id" and ( self.product_id.tracking == "none" or self.auto_lot or (self.lot_name and self.create_lot) ): continue if self._option_required_hook(option): continue self.display_notification( _("{name} is required").format(name=option.name), message_type="danger", title=_("Empty field"), sticky=False, ) self.action_show_step() return False return True def _option_required_hook(self, option_required): """Hook to evaluate is an option is required""" return False def _scanned_location(self, barcode): location = self.env["stock.location"].search(self._barcode_domain(barcode)) if location: self.location_id = location self._set_messagge_info("info", _("Waiting product")) return True else: return False def _barcode_domain(self, barcode): field_name = self.env.context.get("barcode_domain_field", "barcode") return [(field_name, "=", barcode)] def _clean_barcode_scanned(self, barcode): return barcode.rstrip() def on_barcode_scanned(self, barcode): self.barcode = self._clean_barcode_scanned(barcode) def dummy_on_barcode_scanned(self): """To avoid execute operations in onchange environment""" self.process_barcode(self.barcode) def check_location_contidion(self): if not self.location_id: self._set_messagge_info("info", _("Waiting location")) # Remove product when no location has been scanned self.product_id = False return False return True def check_lot_contidion(self): if self.product_id.tracking != "none" and not self.lot_id and not self.lot_name: self._set_messagge_info("info", _("Waiting lot")) return False return True def check_done_conditions(self): result_ok = self.check_location_contidion() if not result_ok: return False if not self.product_id: self._set_messagge_info("info", _("Waiting product")) return False result_ok = self.check_lot_contidion() if not result_ok: return False if ( not self.product_qty and not self._name == "wiz.stock.barcodes.read.inventory" ): self._set_messagge_info("info", _("Waiting quantities")) return False if ( self.option_group_id.barcode_guided_mode == "guided" and not self._check_guided_values() ): return False if self.manual_entry: self._set_messagge_info("success", _("Manual entry OK")) return True def _check_guided_values(self): if ( self.product_id != self.guided_product_id and self.option_group_id.get_option_value("product_id", "forced") ): self._set_messagge_info("more_match", _("Wrong product")) self.product_qty = 0.0 return False if ( self.guided_product_id.tracking != "none" and self.lot_id != self.guided_lot_id and self.option_group_id.get_option_value("lot_id", "forced") ): self._set_messagge_info("more_match", _("Wrong lot")) return False if ( self.location_id != self.guided_location_id and self.option_group_id.get_option_value("location_id", "forced") ): self._set_messagge_info("more_match", _("Wrong location")) return False if ( self.location_dest_id != self.guided_location_dest_id and self.option_group_id.get_option_value("location_dest_id", "forced") ): self._set_messagge_info("more_match", _("Wrong location dest")) return False return True def action_done(self): if not self.manual_entry and not self.product_qty and not self.is_manual_qty: self.product_qty = 1.0 limit_product_qty = float( self.env["ir.config_parameter"] .sudo() .get_param("stock_barcodes.limit_product_qty", "999999") ) if self.product_qty > limit_product_qty: # HACK: Some times users scan a barcode into input element. # At this time, to prevent this we check that the quantity be realistic. self._set_messagge_info("more_match", _("The quantity is huge")) return False if not self.check_done_conditions(): return False self.process_lot_before_done() return True def action_cancel(self): return True def action_product_scaned_post(self, product): self.package_id = False if self.product_id != product and self.lot_id.product_id != product: self.lot_id = False self.product_id = product self.product_uom_id = self.product_id.uom_id self.set_product_qty() def action_packaging_scaned_post(self, packaging): self.packaging_id = packaging if ( self.product_id != packaging.product_id and self.lot_id.product_id != packaging.product_id ): self.lot_id = False self.product_id = packaging.product_id self.set_product_qty() def action_lot_scaned_post(self, lot): if isinstance(lot, str): self.lot_name = lot else: self.lot_id = lot self.set_product_qty() def set_product_qty(self): if ( self.manual_entry or self.is_manual_qty or self.option_group_id.get_option_value("product_qty", "filled_default") ): return elif self.packaging_id: self.packaging_qty = 1.0 self.product_qty = self.packaging_id.qty * self.packaging_qty else: self.packaging_qty = 0.0 self.product_qty = 1.0 def action_clean_lot(self): self.lot_id = False self.lot_name = False self.action_show_step() def action_clean_product(self): self.product_id = False self.action_show_step() def action_clean_package(self): self.package_id = False self.result_package_id = False self.action_show_step() def action_create_package(self): self.result_package_id = self.env["stock.quant.package"].create({}) def action_clean_values(self): options = self.option_group_id.option_ids options_to_clean = options.filtered( lambda op: op.clean_after_done and op.field_name in self ) for option in options_to_clean: if option.field_name == "result_package_id" and self.keep_result_package: continue if option.field_name: setattr(self, option.field_name, False) self.action_show_step() self.product_qty = 0.0 self.packaging_qty = 0.0 self.lot_name = False def action_manual_entry(self): return True def reset_qty(self): self.product_qty = 0 self.packaging_qty = 0 def open_actions(self): self.display_menu = True return self.env.ref( "stock_barcodes.action_stock_barcodes_action_client" ).read()[0] def action_back(self): return self.env.ref("stock.stock_picking_type_action").read()[0] def open_records(self): action = self.action_ids return action def get_option_value(self, field_name, attribute): option = self.option_group_id.option_ids.filtered( lambda op: op.field_name == field_name )[:1] return option[attribute] def action_force_done(self): res = self.with_context(force_create_move=True).action_confirm() self.visible_force_done = False return res @api.model_create_multi def create(self, vals_list): wizards = super().create(vals_list) for wiz in wizards: wiz.action_show_step() return wizards def action_manual_quantity(self): action = self.get_formview_action() form_view = self.env.ref( "stock_barcodes.view_stock_barcodes_read_form_manual_qty" ) action["views"] = [(form_view.id, "form")] action["res_id"] = self.ids[0] return action def action_reopen_wizard(self): return self.get_formview_action() @api.onchange("step") def action_show_step(self): options_required = self.option_group_id.option_ids.filtered("required") self.step = 0 for option in options_required: if not getattr(self, option.field_name, False): if option.field_name == "lot_id" and self.product_id.tracking == "none": continue self.step = option.step break if not self.step: self.step = options_required[:1].step options = self.option_group_id.option_ids.filtered( lambda op: op.step == self.step and op.to_scan ) self._set_messagge_info( "info_page", _("Scan {}").format(", ".join(options.mapped("name"))) ) @api.onchange("package_id") def onchange_package_id(self): if self.manual_entry: self.barcode = self.package_id.name self.process_barcode_package_id() def action_confirm(self): if not self.check_option_required(): self.play_sounds(False) return False record = self.browse(self.ids) record.write(self._convert_to_write(self._cache)) self = record no_increase_qty_done, force_create_move = False, False context = dict(self.env.context) if self._name == "wiz.stock.barcodes.read.picking": no_increase_qty_done = ( context.get("no_increase_qty_done", False) or self.manual_entry ) force_create_move = context.get("force_create_move", False) res = self.with_context( no_increase_qty_done=no_increase_qty_done, force_create_move=force_create_move, ).action_done() self.invalidate_recordset() self.play_sounds(res) self._set_focus_on_qty_input() if force_create_move: # Hide Form Edit self.manual_entry = False self.send_bus_done( "stock_barcodes_scan", "stock_barcodes_edit_manual", { "manual_entry": False, }, ) # Count elements for apply in inventory if self._name == "wiz.stock.barcodes.read.inventory": self.display_read_quant = True self._compute_count_inventory_quants() self.send_bus_done( "stock_barcodes_form_update", "count_apply_inventory", {"count": self.count_inventory_quants}, ) return res def action_add_scan_manual(self): self.manual_entry = True self.send_bus_done( "stock_barcodes_scan", "stock_barcodes_edit_manual", {"manual_entry": True} ) def process_lot_before_done(self): if ( not self.lot_id and self.lot_name and self.product_id and self.product_id.tracking != "none" and self.option_group_id.create_lot ): self.lot_id = self._create_new_lot() return True def play_sounds(self, res): if res: self.send_bus_done( "stock_barcodes_scan", "stock_barcodes_sound", {"sound": "ok", "res_model": self._name, "res_id": self.ids[0]}, ) else: self.send_bus_done( "stock_barcodes_scan", "stock_barcodes_sound", {"sound": "ko", "res_model": self._name, "res_id": self.ids[0]}, ) def _set_focus_on_qty_input(self, field_name=None): if field_name is None: field_name = "product_qty" if field_name == "product_qty" and self.packaging_id: field_name = "packaging_qty" self.send_bus_done( "stock_barcodes_scan", "stock_barcodes_focus", { "action": "focus", "field_name": field_name, "res_model": self._name, "res_id": self.ids[0], }, ) @api.onchange("product_id") def onchange_product_id(self): self.product_uom_id = self.product_id.uom_id @api.onchange("manual_entry") def onchange_manual_entry(self): if self.manual_entry and self.option_group_id.manual_entry_field_focus: self._set_focus_on_qty_input(self.option_group_id.manual_entry_field_focus) def _prepare_lot_vals(self): return { "name": self.lot_name, "product_id": self.product_id.id, "company_id": self.env.company.id, } def _create_new_lot(self): StockProductionLot = self.env["stock.lot"] lot_domain = [ ("name", "=", self.lot_name), ("product_id", "=", self.product_id.id), ] new_lot = StockProductionLot.search(lot_domain) if not new_lot: new_lot = StockProductionLot.create(self._prepare_lot_vals()) return new_lot def action_clean_message(self): self.message = False self.check_option_required() def action_keep_result_package(self): self.keep_result_package = not self.keep_result_package def display_notification( self, message, message_type="warning", title=False, sticky=True ): """Send notifications to web client message_type: [options.type='warning'] 'info', 'success', 'warning', 'danger' or '' sticky: Permanent notification until user removes it """ if self.option_group_id.display_notification and not self.env.context.get( "skip_display_notification", False ): message = { "message": message, "type": message_type, "sticky": sticky, "res_model": self._name, "res_id": self.ids[0], } if title: message["title"] = title self.send_bus_done( "stock_barcodes-{}".format(self.ids[0]), "stock_barcodes_notify-{}".format(self.ids[0]), message, )