Browse files

[FIX] stock: block any duplicate serial numbers

Serial numbers should be unique by design. There are currently some checks
to empeach any duplication into transfers, inventory adjustements or production.
However, a global constraint checking accross all quants is missing. Indeed,
nothing blocks you to have the same serial number used in two sub locations of
your stock. You can also deliver a tracked product then produce the same number.

This commit adds a constraint at the stock move lines validation. After the creation of
the quant (as it's tracked by serial number, it should always be a creation and
not an update). We check that the serial number is not present elsewhere except in
location 'supplier', 'production' and 'inventory. Those 3 last one are virtuals ones
and the quantity is most of the time negatives.

This commit also add a correction process for inventory adjutment. If a serial number
is stored in loc1 and the inventory aims to change quantity to 1 for sn1 in loc2.
We raise a correction wizard to warn the user there is a problem and to add in the
inventory some line to balance the new stock moves.

Ex: we produce sn1, we sell it to a customer. The quant situation is the following
Production : -1
Customer   : +1
We create an inventory adjustement to increase the quantity of sn1 in Stock. Without
the commit, the situation whould have been
Production : -1
Inventory  : -1
Customer   : +1
Stock      : +1
What it does is inverting the first movement (stock -> customer) thanks to an inventory line
The situation after the inventory is the following
Production : -1
Stock      : +1

Task : 1924758
  • Loading branch information...
Whenrow committed Jan 30, 2019
1 parent fa5c987 commit 4711703a4c044c134be8c8485bf23660d5a568e8
@@ -5,6 +5,7 @@
from datetime import datetime, timedelta

from odoo.fields import Datetime as Dt
from odoo.exceptions import UserError
from odoo.addons.mrp.tests.common import TestMrpCommon

@@ -719,3 +720,40 @@ def test_product_produce_uom(self):
move_line_finished = mo.move_finished_ids.mapped('move_line_ids').filtered(lambda m: m.qty_done)
self.assertEqual(move_line_finished.qty_done, 1)
self.assertEqual(move_line_finished.product_uom_id, unit, 'Should be 1 unit since the tracking is serial.')

def test_duplicate_serial_1(self):
"""The production of a serial tracked product is forbidden if the serial
number has already be produced"""
mo, bom, p_final, compo1, compo2 = self.generate_mo('serial', qty_final=1, qty_base_1=1, qty_base_2=1)
sn1 = self.env['stock.production.lot'].create({
'name': 'sn1',
# First production
produce_form = Form(self.env['mrp.product.produce'].with_context({
'active_ids': [],
produce_form.lot_id = sn1
product_produce =

# move product to another location
quant = self.env['stock.quant']._gather(p_final, self.env.ref('stock.stock_location_stock'))
quant.location_id = self.env.ref('stock.stock_location_customers')

# Second production
mo2 = mo.copy()
produce_form = Form(self.env['mrp.product.produce'].with_context({
'active_ids': [],
produce_form.lot_id = sn1
product_produce =
with self.assertRaises(UserError):
@@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from . import common
from odoo.exceptions import except_orm
from odoo.exceptions import except_orm, UserError
from odoo.tests import Form

@@ -97,11 +97,40 @@ def test_manufacturing_scrap(self):
# Scrap Product Wood with lot.
self.env['stock.scrap'].with_context(active_model='mrp.production',{'product_id':, 'scrap_qty': 1.0, 'product_uom_id':, 'location_id': location_id, 'lot_id':, 'production_id':})

#Check scrap move is created for this production order.
#TODO: should check with scrap objects link in between
def test_manufacturing_scrap_2(self):
""" Testing to do a scrap of serial tracked consumed materialself.
Then consuming again the same serial number. We should be blocked """

# scrap_move = production_3.move_raw_ids.filtered(lambda x: x.product_id == self.product_2 and x.scrapped)
# self.assertTrue(scrap_move, "There are no any scrap move created for production order.")
mo, bom, p_final, compo1, compo2 = self.generate_mo(tracking_base_1='serial', qty_final=1, qty_base_1=1, qty_base_2=1)
sn1 = self.env['stock.production.lot'].create({
'name': 'sn1',
stock_inv_compo1 = self.env['stock.inventory'].create({
'name': 'Stock Inventory for Stick',
'filter': 'product',
'line_ids': [
(0, 0, {'product_id':, 'product_uom_id':, 'product_qty': 1, 'prod_lot_id':, 'location_id': self.ref('stock.stock_location_14')}),


# Scrap Product.
scrap_id = self.env['stock.scrap'].with_context(active_model='mrp.production',{'product_id':, 'scrap_qty': 1.0, 'lot_id':, 'product_uom_id':, 'location_id': self.ref('stock.stock_location_14'), 'production_id':})

# Try to consume sn1
produce_form = Form(self.env['mrp.product.produce'].with_context({
'active_ids': [],
product_produce =
product_produce.produce_line_ids.filtered(lambda line: line.product_id == compo1).lot_id =
with self.assertRaises(UserError):

class TestKitPicking(common.TestMrpCommon):
@@ -52,6 +52,7 @@

@@ -88,6 +88,12 @@ def _default_location_id(self):
readonly=True, states={'draft': [('readonly', False)]},
help="Specify Product Category to focus your inventory on a particular Category.")
exhausted = fields.Boolean('Include Exhausted Products', readonly=True, states={'draft': [('readonly', False)]})
show_locations = fields.Boolean(compute='_compute_show_locations')

def _compute_show_locations(self):
group = self.user_has_groups("stock.group_stock_multi_locations")
self.show_locations = any([line.correction_line != 'none' for line in self.line_ids]) or group
@api.depends('product_id', 'line_ids.product_qty')
@@ -164,6 +170,46 @@ def action_reset_product_qty(self):
return True

def action_validate(self):
# Check the serial numbers uniquiness
serials = self.line_ids.filtered(lambda l: l.product_id.tracking == 'serial' and l.prod_lot_id and l.theoretical_qty < l.product_qty)
wiz_lines = []
for line in serials:
similar_quant = self.env['stock.quant'].search([
('lot_id', '=',,
('location_id', '!=',,
('location_id.usage', 'not in', ('supplier', 'inventory', 'production')),
('location_id.scrap_location', '=', True),
('quantity', '>', 0)
# Extract the quant to correct
if len(similar_quant) > 1:
same_usage = similar_quant.filtered(lambda q: q.location_id.usage == line.location_id.usage)
if same_usage:
similar_quant = same_usage[0]
similar_quant = similar_quant[0]
# Fill the wizard lines
if similar_quant and similar_quant.quantity - sum(self.line_ids.filtered(lambda line: line.correction_line == 'warning' and line.prod_lot_id == similar_quant.lot_id).mapped('theoretical_qty')) != 0:
if wiz_lines:
wiz = self.env['stock.duplicate.warning'].create({
'tracking_line_ids': [(0, 0, line) for line in wiz_lines]
return {
'name': _('Duplicated serial numbers'),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'stock.duplicate.warning',
'target': 'new',
inventory_lines = self.line_ids.filtered(lambda l: l.product_id.tracking in ['lot', 'serial'] and not l.prod_lot_id and l.theoretical_qty != l.product_qty)
lines = self.line_ids.filtered(lambda l: float_compare(l.product_qty, 1, precision_rounding=l.product_uom_id.rounding) > 0 and l.product_id.tracking == 'serial' and l.prod_lot_id)
if inventory_lines and not lines:
@@ -356,6 +402,7 @@ class InventoryLine(models.Model):
inventory_location_id = fields.Many2one(
'stock.location', 'Inventory Location', related='inventory_id.location_id', related_sudo=False, readonly=False)
product_tracking = fields.Selection('Tracking', related='product_id.tracking', readonly=True)
correction_line = fields.Selection([('none', None), ('warning', 'Warning'), ('danger', 'Danger')], default='none')
@api.depends('location_id', 'product_id', 'package_id', 'product_uom_id', 'company_id', 'prod_lot_id', 'partner_id')
@@ -399,6 +446,9 @@ def create(self, vals_list):

def write(self,vals):
# reset decoration style
if self.correction_line == 'danger' and not vals.get('correction_line'):
vals['correction_line'] = 'none'
res = super(InventoryLine, self).write(vals)
return res
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from collections import Counter
from itertools import groupby

from odoo import api, fields, models, _
from odoo.addons import decimal_precision as dp
@@ -451,6 +452,52 @@ def _action_done(self, cancel_backorder=False):
Quant._update_available_quantity(ml.product_id, ml.location_id, taken_from_untracked_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id)
Quant._update_available_quantity(ml.product_id, ml.location_dest_id, quantity, lot_id=ml.lot_id, package_id=ml.result_package_id, owner_id=ml.owner_id, in_date=in_date)
done_ml |= ml

# Unicity of serial tracked product. Search in quants if the sn is
# already present in stock or delivered to a customer
if ml.tracking == 'serial' and ml.lot_id: # see test_inventory_4:
similar_quant = self.env['stock.quant'].search([
('lot_id', '=',,
('location_id.usage', 'not in', ('supplier', 'inventory', 'production')),
('location_id.scrap_location', '=', True)
error_message = _('The serial number %s for product %s is already in another location. Correct your inventory with an inventory adjustment before validating this product move.' % (, ml.product_id.display_name))

# Check if SN has been scrapped
scrapped = any([quant.location_id.scrap_location for quant in similar_quant])
if scrapped and not ml.location_dest_id.scrap_location:
raise UserError(error_message)
if similar_quant:
# the following step will group the quants by location usage
# if similar_quant =
# 1 in customer in PACK1
# 1 in customer in PACK2
# -1 in stock in PACK3
# -1 in output without pack
# 1 in stock without pack
# The group_by becomes
# 2 in customer
# -1 in internal (+1 -1 -1 = -1)

# First, we sort the quant by location usage
sq = sorted([(q.location_id.usage, q.quantity) for q in similar_quant], key=lambda x: x[0])
# -> [('internal', 1), ('customer', 1), ('internal', -1), ('internal', -1), ('customer', 1)]

# Second, we group by location usage and sum the quantity in the groups
sq = [(x[0], sum([y[1] for y in x[1]])) for x in groupby(sq, key=lambda x:x[0])]
# -> [('internal', [('internal',1), ('internal', -1), ('internal', 1)]), ('customer', [('customer', 1), ('customer', 1)])]
# -> [('internal', -1), ('customer', 2)]

# Third, we search group with quantity > 1
duplicates = any([x[1] > 1 for x in sq])
# -> duplicates = True (for customer usage)

# Fourth we sum the total quantity
quantity = sum([x[1] >= 1 for x in sq])
if duplicates or quantity > 1:
raise UserError(error_message)

# Reset the reserved quantity as we just moved it to the destination location.
(self - ml_to_delete).with_context(bypass_reservation_update=True).write({
'product_uom_qty': 0.00,
@@ -73,12 +73,6 @@ def check_product_id(self):
if any(elem.product_id.type != 'product' for elem in self):
raise ValidationError(_('Quants cannot be created for consumables or services.'))

def check_quantity(self):
for quant in self:
if float_compare(quant.quantity, 1, precision_rounding=quant.product_uom_id.rounding) > 0 and quant.lot_id and quant.product_id.tracking == 'serial':
raise ValidationError(_('A serial number should only be linked to a single product.'))

def check_location_id(self):
for quant in self:
@@ -43,6 +43,7 @@ def setUp(self):
self.productC = self.ProductObj.create({'name': 'Product C', 'type': 'product'})
self.productD = self.ProductObj.create({'name': 'Product D', 'type': 'product'})
self.productE = self.ProductObj.create({'name': 'Product E', 'type': 'product'})
self.productS = self.ProductObj.create({'name': 'Product S', 'type': 'product', 'tracking': 'serial'})

# Configure unit of measure.
self.uom_kg = self.env['uom.uom'].search([('category_id', '=', self.categ_kgm), ('uom_type', '=', 'reference')], limit=1)
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.exceptions import ValidationError
from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase

@@ -102,7 +102,7 @@ def test_inventory_3(self):
inventory.line_ids.prod_lot_id = lot1
inventory.line_ids.product_qty = 2

with self.assertRaises(ValidationError):
with self.assertRaises(UserError):

def test_inventory_4(self):
@@ -297,3 +297,64 @@ def test_inventory_7(self):
self.assertEqual(len(inventory.line_ids), 1)
self.assertEqual(inventory.line_ids.theoretical_qty, 2)

def test_duplicate_serial_1(self):
# add sn1 for a serial tracked products in stock
sn1 = self.env['stock.production.lot'].create({
'name': 'SN1'
inventory = self.env['stock.inventory'].create({
'name': 'serial product',
'filter': 'product',
'exhausted': True, # should be set by an onchange
inventory.line_ids.prod_lot_id = sn1
inventory.line_ids.product_qty = 1
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product2, self.stock_location), 1.0)

# Make a move to customer for sn1
move_delivery = self.env['stock.move'].create({
'name': 'delivery1',
'product_uom_qty': 1.0,

self.assertEqual(move_delivery.state, 'assigned')
move_delivery.move_line_ids.qty_done = 1
self.assertEqual(move_delivery.state, 'done')
self.assertEqual(self.env['stock.quant']._gather(self.product2, self.customer_location).quantity, 1.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product2, self.stock_location), 0.0)

# Create a new inventory and try to put sn1 in stock
inventory = self.env['stock.inventory'].create({
'name': 'serial product',
'filter': 'product',
'exhausted': True, # should be set by an onchange
inventory.line_ids.prod_lot_id = sn1
inventory.line_ids.product_qty = 1
res_dict_for_duplicate = inventory.action_validate()
wizard_duplicate = self.env[(res_dict_for_duplicate.get('res_model'))].browse(res_dict_for_duplicate.get('res_id'))
self.assertEqual(len(inventory.line_ids), 2, "There is a missing line")
correction_line = inventory.line_ids.filtered(lambda line: line.correction_line == 'warning')
self.assertEqual(correction_line.location_id, self.customer_location, "There is a missing line")
self.assertEqual(correction_line.theoretical_qty, 1, "The theoretical quantity is wrong")
self.assertEqual(correction_line.product_qty, 0, "The real quantity is wrong")
self.assertEqual(inventory.state, 'done')
self.assertEqual(self.env['stock.quant']._gather(self.product2, self.customer_location).quantity, 0)
self.assertEqual(self.env['stock.quant']._gather(self.product2, self.stock_location).quantity, 1.0)
Oops, something went wrong.

0 comments on commit 4711703

Please sign in to comment.