diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 15f25475249..8db9bde9df2 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -671,19 +671,12 @@ class MrpProduction(models.Model): @api.onchange('lot_producing_id') def _onchange_lot_producing(self): - if self.product_id.tracking == 'serial': - if self.env['stock.move.line'].search_count([ - ('company_id', '=', self.company_id.id), - ('product_id', '=', self.product_id.id), - ('lot_id', '=', self.lot_producing_id.id), - ('state', '!=', 'cancel') - ]): - return { - 'warning': { - 'title': _('Warning'), - 'message': _('Existing Serial number (%s). Please correct the serial numbers encoded.') % self.lot_producing_id.name - } - } + if self.product_id.tracking == 'serial' and self.lot_producing_id: + message, dummy = self.env['stock.quant']._check_serial_number(self.product_id, + self.lot_producing_id, + self.company_id) + if message: + return {'warning': {'title': _('Warning'), 'message': message}} @api.onchange('bom_id') def _onchange_workorder_ids(self): diff --git a/addons/mrp/models/stock_scrap.py b/addons/mrp/models/stock_scrap.py index bec0bf27371..0b3f73dc4f9 100644 --- a/addons/mrp/models/stock_scrap.py +++ b/addons/mrp/models/stock_scrap.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from odoo import _, api, fields, models class StockScrap(models.Model): @@ -34,3 +34,19 @@ class StockScrap(models.Model): else: vals.update({'raw_material_production_id': self.production_id.id}) return vals + + @api.onchange('lot_id') + def _onchange_serial_number(self): + if self.product_id.tracking == 'serial' and self.lot_id: + if self.production_id: + message, recommended_location = self.env['stock.quant']._check_serial_number(self.product_id, + self.lot_id, + self.company_id, + self.location_id, + self.production_id.location_dest_id) + if message: + if recommended_location: + self.location_id = recommended_location + return {'warning': {'title': _('Warning'), 'message': message}} + else: + return super()._onchange_serial_number() diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py index e42eb695af8..b84341fd69f 100644 --- a/addons/mrp/tests/test_order.py +++ b/addons/mrp/tests/test_order.py @@ -1523,3 +1523,45 @@ class TestMrpOrder(TestMrpCommon): # of a conversion 187.5ml = 0.188L # thus creating an extra line with 'product_uom_qty': 0.5 self.assertEqual(len(mo_product_final_form.move_raw_ids.move_line_ids), 1, 'One move line should exist for the MO.') + + def test_mo_sn_warning(self): + """ Checks that when a MO where the final product is tracked by serial, a warning pops up if + the `lot_producting_id` has previously been used already (i.e. dupe SN). Also checks if a + scrap linked to a MO has its sn warning correctly pop up. + """ + self.stock_location = self.env.ref('stock.stock_location_stock') + mo, _, p_final, _, _ = self.generate_mo(tracking_final='serial', qty_base_1=1, qty_final=1) + self.assertEqual(len(mo), 1, 'MO should have been created') + + sn1 = self.env['stock.production.lot'].create({ + 'name': 'serial1', + 'product_id': p_final.id, + 'company_id': self.env.company.id, + }) + + self.env['stock.quant']._update_available_quantity(p_final, self.stock_location, 1, lot_id=sn1) + mo.lot_producing_id = sn1 + + warning = False + warning = mo._onchange_lot_producing() + self.assertTrue(warning, 'Reuse of existing serial number not detected') + self.assertEqual(list(warning.keys())[0], 'warning', 'Warning message was not returned') + + mo.action_generate_serial() + sn2 = mo.lot_producing_id + mo.button_mark_done() + + # scrap linked to MO but with wrong SN location + scrap = self.env['stock.scrap'].create({ + 'product_id': p_final.id, + 'product_uom_id': self.uom_unit.id, + 'production_id': mo.id, + 'location_id': self.stock_location_14.id, + 'lot_id': sn2.id + }) + + warning = False + warning = scrap._onchange_serial_number() + self.assertTrue(warning, 'Use of wrong serial number location not detected') + self.assertEqual(list(warning.keys())[0], 'warning', 'Warning message was not returned') + self.assertEqual(scrap.location_id, mo.location_dest_id, 'Location was not auto-corrected') diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py index ac854352d32..0081c357c20 100644 --- a/addons/stock/models/stock_inventory.py +++ b/addons/stock/models/stock_inventory.py @@ -590,6 +590,37 @@ class InventoryLine(models.Model): self.product_qty = theoretical_qty self.theoretical_qty = theoretical_qty + @api.onchange('product_qty', 'prod_lot_id') + def _onchange_serial_number(self): + if self.prod_lot_id and self.product_id.tracking == 'serial' and not float_is_zero(self.product_qty, self.product_id.uom_id.rounding): + dupe_sn_lines = self.env['stock.inventory.line'] + message = _('Is this expected? For example this can happen if pick-pack-ship was not completed in the right order. In this case the issue will be solved ' + 'automatically once all steps are completed. Otherwise, you can adjust quantities in these locations.') + # first check current inventory for non-zero dupe SNs + if self.inventory_id: + dupe_sn_lines |= self.inventory_id.line_ids.filtered( + lambda l: l.product_id == self.product_id + and l.prod_lot_id == self.prod_lot_id + and l.location_id.id is not self.location_id.id + and not float_is_zero(l.product_qty, l.product_id.uom_id.rounding)) + if len(dupe_sn_lines) > 0: + message = _('There is already one or more inventory adjustment line(s) counting the Serial Number (%s) at the location(s): %s.\n\n', + self.prod_lot_id.name, ', '.join(dupe_sn_lines.location_id.mapped('display_name'))) + message + return {'warning': {'title': _('Warning'), 'message': message}} + # then check locations not in inventory for dupe SNs + if self.inventory_id and self.inventory_id.location_ids: + quants = self.env['stock.quant'].search([('product_id', '=', self.product_id.id), + ('lot_id', '=', self.prod_lot_id.id), + ('quantity', '!=', 0), + ('location_id', 'not in', self.inventory_id.location_ids.ids), + '|', ('location_id.usage', '=', 'customer'), + '&', ('company_id', '=', self.company_id.id), + ('location_id.usage', 'in', ('internal', 'transit'))]) + if quants: + message = _('The Serial Number (%s) is already used in these location(s): %s.\n\n', + self.prod_lot_id.name, ', '.join(quants.location_id.mapped('display_name'))) + message + return {'warning': {'title': _('Warning'), 'message': message}} + @api.model_create_multi def create(self, vals_list): """ Override to handle the case we create inventory line without diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 4322e84eb74..4834b02613c 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -896,16 +896,19 @@ class StockMove(models.Model): quantity_done = sum(ml.product_uom_id._compute_quantity(ml.qty_done, self.product_uom) for ml in self.move_line_ids.filtered(lambda ml: not ml.lot_id and ml.lot_name)) quantity_done += self.product_id.uom_id._compute_quantity(len(self.lot_ids), self.product_uom) self.update({'quantity_done': quantity_done}) - used_lots = self.env['stock.move.line'].search([ - ('company_id', '=', self.company_id.id), - ('product_id', '=', self.product_id.id), - ('lot_id', 'in', self.lot_ids.ids), - ('move_id', '!=', self._origin.id), - ('state', '!=', 'cancel') - ]) - if used_lots: + + quants = self.env['stock.quant'].search([('product_id', '=', self.product_id.id), + ('lot_id', 'in', self.lot_ids.ids), + ('quantity', '!=', 0), + '|', ('location_id.usage', '=', 'customer'), + '&', ('company_id', '=', self.company_id.id), + ('location_id.usage', 'in', ('internal', 'transit'))]) + if quants: + sn_to_location = "" + for quant in quants: + sn_to_location += _("\n(%s) exists in location %s", quant.lot_id.display_name, quant.location_id.display_name) return { - 'warning': {'title': _('Warning'), 'message': _('Existing Serial numbers (%s). Please correct the serial numbers encoded.') % ','.join(used_lots.lot_id.mapped('display_name'))} + 'warning': {'title': _('Warning'), 'message': _('Existing Serial numbers. Please correct the serial numbers encoded:') + sn_to_location} } @api.onchange('move_line_ids', 'move_line_nosuggest_ids', 'picking_type_id') diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index 85aa0610e91..699473d502f 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -133,6 +133,7 @@ class StockMoveLine(models.Model): help him. This includes: - automatically switch `qty_done` to 1.0 - warn if he has already encoded `lot_name` in another move line + - warn (and update if appropriate) if the SN is in a different source location than selected """ res = {} if self.product_id.tracking == 'serial': @@ -147,17 +148,25 @@ class StockMoveLine(models.Model): if counter.get(self.lot_name) and counter[self.lot_name] > 1: message = _('You cannot use the same serial number twice. Please correct the serial numbers encoded.') elif not self.lot_id: - counter = self.env['stock.production.lot'].search_count([ - ('company_id', '=', self.company_id.id), - ('product_id', '=', self.product_id.id), - ('name', '=', self.lot_name), - ]) - if counter > 0: - message = _('Existing Serial number (%s). Please correct the serial number encoded.') % self.lot_name + lots = self.env['stock.production.lot'].search([('product_id', '=', self.product_id.id), + ('name', '=', self.lot_name), + ('company_id', '=', self.company_id.id)]) + quants = lots.quant_ids.filtered(lambda q: q.quantity != 0 and q.location_id.usage in ['customer', 'internal', 'transit']) + if quants: + message = _('Serial number (%s) already exists in location(s): %s. Please correct the serial number encoded.', self.lot_name, ', '.join(quants.location_id.mapped('display_name'))) elif self.lot_id: counter = Counter([line.lot_id.id for line in move_lines_to_check]) if counter.get(self.lot_id.id) and counter[self.lot_id.id] > 1: message = _('You cannot use the same serial number twice. Please correct the serial numbers encoded.') + else: + # check if in correct source location + message, recommended_location = self.env['stock.quant']._check_serial_number(self.product_id, + self.lot_id, + self.company_id, + self.location_id, + self.picking_id.location_id) + if recommended_location: + self.location_id = recommended_location if message: res['warning'] = {'title': _('Warning'), 'message': message} return res diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index 27615327ecc..016faad28fd 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -369,6 +369,15 @@ class StockQuant(models.Model): } return {'warning': warning} + @api.onchange('lot_id') + def _onchange_serial_number(self): + if self.lot_id and self.product_id.tracking == 'serial': + message, dummy = self.env['stock.quant']._check_serial_number(self.product_id, + self.lot_id, + self.company_id) + if message: + return {'warning': {'title': _('Warning'), 'message': message}} + @api.model def _update_available_quantity(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, in_date=None): """ Increase or decrease `reserved_quantity` of a set of quants for a given set of @@ -643,6 +652,68 @@ class StockQuant(models.Model): }) return action + @api.model + def _check_serial_number(self, product_id, lot_id, company_id, source_location_id=None, ref_doc_location_id=None): + """ Checks for duplicate serial numbers (SN) when assigning a SN (i.e. no source_location_id) + and checks for potential incorrect location selection of a SN when using a SN (i.e. + source_location_id). Returns warning message of all locations the SN is located at and + (optionally) a recommended source location of the SN (when using SN from incorrect location). + This function is designed to be used by onchange functions across differing situations including, + but not limited to scrap, incoming picking SN encoding, and outgoing picking SN selection. + + :param product_id: `product.product` product to check SN for + :param lot_id: `stock.production.lot` SN to check + :param company_id: `res.company` company to check against (i.e. we ignore duplicate SNs across + different companies) + :param source_location_id: `stock.location` optional source location if using the SN rather + than assigning it + :param ref_doc_location_id: `stock.location` optional reference document location for + determining recommended location. This is param expected to only be used when a + `source_location_id` is provided. + :return: tuple(message, recommended_location) If not None, message is a string expected to be + used in warning message dict and recommended_location is a `location_id` + """ + message = None + recommended_location = None + if product_id.tracking == 'serial': + quants = self.env['stock.quant'].search([('product_id', '=', product_id.id), + ('lot_id', '=', lot_id.id), + ('quantity', '!=', 0), + '|', ('location_id.usage', '=', 'customer'), + '&', ('company_id', '=', company_id.id), + ('location_id.usage', 'in', ('internal', 'transit'))]) + sn_locations = quants.mapped('location_id') + if quants: + if not source_location_id: + # trying to assign an already existing SN + message = _('The Serial Number (%s) is already used in these location(s): %s.\n\n' + 'Is this expected? For example this can occur if a delivery operation is validated ' + 'before its corresponding receipt operation is validated. In this case the issue will be solved ' + 'automatically once all steps are completed. Otherwise, the serial numbershould be corrected to ' + 'prevent inconsistent data.', + lot_id.name, ', '.join(sn_locations.mapped('display_name'))) + + elif source_location_id and source_location_id not in sn_locations: + # using an existing SN in the wrong location + recommended_location = self.env['stock.location'] + if ref_doc_location_id: + for location in sn_locations: + if ref_doc_location_id.parent_path in location.parent_path: + recommended_location = location + break + else: + for location in sn_locations: + if location.usage != 'customer': + recommended_location = location + break + if recommended_location: + message = _('Serial number (%s) is not located in %s, but is located in location(s): %s. Source location for this move will be changed to %s', + lot_id.name, source_location_id.display_name, ', '.join(sn_locations.mapped('display_name')), recommended_location.display_name) + else: + message = _('Serial number (%s) is not located in %s, but is located in location(s): %s. Please correct this to prevent inconsistent data.', + lot_id.name, source_location_id.display_name, ', '.join(sn_locations.mapped('display_name'))) + return message, recommended_location + class QuantPackage(models.Model): """ Packages containing quants and/or other packages """ diff --git a/addons/stock/models/stock_scrap.py b/addons/stock/models/stock_scrap.py index 6f8e303154b..d7cdb2cd80c 100644 --- a/addons/stock/models/stock_scrap.py +++ b/addons/stock/models/stock_scrap.py @@ -96,6 +96,19 @@ class StockScrap(models.Model): self.location_id = False self.scrap_location_id = False + @api.onchange('lot_id') + def _onchange_serial_number(self): + if self.product_id.tracking == 'serial' and self.lot_id: + message, recommended_location = self.env['stock.quant']._check_serial_number(self.product_id, + self.lot_id, + self.company_id, + self.location_id, + self.picking_id.location_dest_id) + if message: + if recommended_location: + self.location_id = recommended_location + return {'warning': {'title': _('Warning'), 'message': message}} + @api.ondelete(at_uninstall=False) def _unlink_except_done(self): if 'done' in self.mapped('state'): diff --git a/addons/stock/tests/test_inventory.py b/addons/stock/tests/test_inventory.py index c51425c41db..4453f4555b0 100644 --- a/addons/stock/tests/test_inventory.py +++ b/addons/stock/tests/test_inventory.py @@ -936,3 +936,53 @@ class TestInventory(TransactionCase): Inventory._run_conflict_inventory_tasks() neg_qty_inventories = Inventory.search([('is_conflict_inventory', '=', True)]) self.assertEqual(len(neg_qty_inventories), 0) + + def test_inventory_sn_warning(self): + """ Check that warnings pop up when duplicate SNs added. + Two cases covered: + - Check for dupes within the inventory (i.e. inventory adjustment lines) + - Check for dupes not in inventory (i.e. existing quants w/ same SN name) + """ + # check duplicated SN within inventory adjustment + lot1 = self.env['stock.production.lot'].create({ + 'name': 'sn1', + 'product_id': self.product2.id, + 'company_id': self.env.company.id, + }) + self.env['stock.quant']._update_available_quantity(self.product2, self.pack_location, 1, lot_id=lot1) + + inventory = self.env['stock.inventory'].create({ + 'name': 'Serial Onchange Check', + 'location_ids': [self.stock_location.id, self.pack_location.id], + 'product_ids': [(4, self.product2.id)], + }) + + inventory.action_start() + self.assertEqual(len(inventory.line_ids), 1) + + inventory_line = self.env['stock.inventory.line'].create({ + 'inventory_id': inventory.id, + 'location_id': self.stock_location.id, + 'product_id': self.product2.id, + 'prod_lot_id': lot1.id, + 'product_qty': 1 + }) + warning = False + warning = inventory_line._onchange_serial_number() + self.assertTrue(warning, 'Multiple lines with same serial number not detected') + self.assertEqual(list(warning.keys())[0], 'warning', 'Warning message was not returned') + + # check duplicate SN outside of inventory adjustment + lot2 = self.env['stock.production.lot'].create({ + 'name': 'sn2', + 'product_id': self.product2.id, + 'company_id': self.env.company.id, + }) + self.env['stock.quant']._update_available_quantity(self.product2, self.customer_location, 1, lot_id=lot2) + inventory_line.write({ + 'prod_lot_id': lot2.id, + }) + warning = False + warning = inventory_line._onchange_serial_number() + self.assertTrue(warning, 'Reuse of existing serial number not detected') + self.assertEqual(list(warning.keys())[0], 'warning', 'Warning message was not returned') diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py index 8085dc0cb77..8092d717aad 100644 --- a/addons/stock/tests/test_move.py +++ b/addons/stock/tests/test_move.py @@ -3920,6 +3920,41 @@ class StockMove(TransactionCase): insufficient_qty_wizard.action_done() self.assertEqual(self.env['stock.quant']._gather(self.product, self.stock_location).quantity, -11) + def test_scrap_7_sn_warning(self): + """ Check serial numbers are correctly double checked """ + + child_loc1 = self.env['stock.location'].create({ + 'name': "child_location1", + 'usage': 'internal', + 'location_id': self.stock_location.id + }) + child_loc2 = self.env['stock.location'].create({ + 'name': "child_location2", + 'usage': 'internal', + 'location_id': self.stock_location.id + }) + + lot1 = self.env['stock.production.lot'].create({ + 'name': 'serial1', + 'product_id': self.product_serial.id, + 'company_id': self.env.company.id, + }) + + self.env['stock.quant']._update_available_quantity(self.product_serial, child_loc1, 1, lot1) + + scrap = self.env['stock.scrap'].create({ + 'product_id': self.product_serial.id, + 'product_uom_id': self.uom_unit.id, + 'location_id': child_loc2.id, + 'lot_id': lot1.id + }) + + warning = False + warning = scrap._onchange_serial_number() + self.assertTrue(warning, 'Use of wrong serial number location not detected') + self.assertEqual(list(warning.keys())[0], 'warning', 'Warning message was not returned') + self.assertEqual(scrap.location_id, child_loc1, 'Location was not auto-corrected') + def test_in_date_1(self): """ Check that moving a tracked quant keeps the incoming date. """ @@ -4489,3 +4524,60 @@ class StockMove(TransactionCase): line1_result_package = picking.move_line_ids[0].result_package_id line2_result_package = picking.move_line_ids[1].result_package_id self.assertNotEqual(line1_result_package, line2_result_package, "Product and Product1 should be in a different package.") + + def test_move_sn_warning(self): + """ Check that warnings pop up when duplicate SNs added or when SN isn't in + expected location. + Two cases covered: + - Check for dupes when assigning serial number to a stock move + - Check for dupes when assigning serial number to a stock move line + """ + + lot1 = self.env['stock.production.lot'].create({ + 'name': 'serial1', + 'product_id': self.product_serial.id, + 'company_id': self.env.company.id, + }) + + self.env['stock.quant']._update_available_quantity(self.product_serial, self.pack_location, 1, lot1) + + move = self.env['stock.move'].create({ + 'name': 'test sn', + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + 'product_id': self.product_serial.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }) + + move_line = self.env['stock.move.line'].create({ + 'move_id': move.id, + 'product_id': move.product_id.id, + 'qty_done': 1, + 'product_uom_id': move.product_uom.id, + 'location_id': move.location_id.id, + 'location_dest_id': move.location_dest_id.id, + 'lot_name': lot1.name, + }) + + warning = False + warning = move_line._onchange_serial_number() + self.assertTrue(warning, 'Reuse of existing serial number (name) not detected') + self.assertEqual(list(warning.keys())[0], 'warning', 'Warning message was not returned') + + move_line.write({ + 'lot_name': False, + 'lot_id': lot1.id + }) + + warning = False + warning = move_line._onchange_serial_number() + self.assertTrue(warning, 'Reuse of existing serial number (record) not detected') + self.assertEqual(list(warning.keys())[0], 'warning', 'Warning message was not returned') + self.assertEqual(move_line.location_id, self.pack_location, 'Location was not auto-corrected') + + move.lot_ids = lot1 + warning = False + warning = move._onchange_lot_ids() + self.assertTrue(warning, 'Reuse of existing serial number (record) not detected') + self.assertEqual(list(warning.keys())[0], 'warning', 'Warning message was not returned') diff --git a/addons/stock/tests/test_quant_inventory_mode.py b/addons/stock/tests/test_quant_inventory_mode.py index dede2ec6d07..1d58b264d31 100644 --- a/addons/stock/tests/test_quant_inventory_mode.py +++ b/addons/stock/tests/test_quant_inventory_mode.py @@ -220,3 +220,33 @@ class TestEditableQuant(TransactionCase): # Try to write on quant with permission quant.with_user(user_admin).write({'inventory_quantity': 8}) self.assertEqual(quant.quantity, 8) + + def test_sn_warning(self): + """ Checks that a warning is given when reusing an existing SN + in inventory mode. + """ + + sn1 = self.env['stock.production.lot'].create({ + 'name': 'serial1', + 'product_id': self.product_tracked_sn.id, + 'company_id': self.env.company.id, + }) + + self.Quant.create({ + 'product_id': self.product_tracked_sn.id, + 'location_id': self.room1.id, + 'inventory_quantity': 1, + 'lot_id': sn1.id + }) + + dupe_sn = self.Quant.create({ + 'product_id': self.product_tracked_sn.id, + 'location_id': self.room2.id, + 'inventory_quantity': 1, + 'lot_id': sn1.id + }) + + warning = False + warning = dupe_sn._onchange_serial_number() + self.assertTrue(warning, 'Reuse of existing serial number not detected') + self.assertEqual(list(warning.keys())[0], 'warning', 'Warning message was not returned')