[IMP] mrp,stock: improve duplicate SN warnings

Add and improve onchange warnings when a duplicate SN is used in
following cases: inventory, picking (any type), manufacturing, scrap,
and "Update Quantity" (i.e. directly edit quants from product form).

Improvement includes:
- include location where the existing SN is
- auto-correct source location when appropriate (e.g. trying to scrap a
  SN in the wrong location)

The goal of this is to prevent but not restrict duplicate SNs so users
can have some flexibility (especially if a dupe SN occurs because of an
error such as doing pick-pack-ship out of order.)

closes odoo/odoo#61287

Task: 1924758
Signed-off-by: Arnold Moyaux <amoyaux@users.noreply.github.com>
This commit is contained in:
Tiffany Chang (tic)
2021-02-10 09:52:37 +00:00
parent 71499bbd23
commit 83a43af4cb
11 changed files with 380 additions and 30 deletions
+6 -13
View File
@@ -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):
+17 -1
View File
@@ -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()
+42
View File
@@ -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')
+31
View File
@@ -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
+12 -9
View File
@@ -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')
+16 -7
View File
@@ -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
+71
View File
@@ -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 """
+13
View File
@@ -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'):
+50
View File
@@ -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')
+92
View File
@@ -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')
@@ -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')