diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index c251d0ac163..5f38eb52684 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -5,7 +5,7 @@ from datetime import datetime
from dateutil import relativedelta
from itertools import groupby
from operator import itemgetter
-from re import search as regex_search, split as regex_split
+from re import findall as regex_findall, split as regex_split
from odoo import api, fields, models, _, SUPERUSER_ID
from odoo.exceptions import UserError
@@ -167,7 +167,8 @@ class StockMove(models.Model):
package_level_id = fields.Many2one('stock.package_level', 'Package Level')
picking_type_entire_packs = fields.Boolean(related='picking_type_id.show_entire_packs', readonly=True)
display_assign_serial = fields.Boolean(compute='_compute_display_assign_serial')
- next_serial = fields.Char('Next Serial Number')
+ next_serial = fields.Char('First SN')
+ next_serial_count = fields.Integer('Number of SN')
@api.onchange('product_id', 'picking_type_id')
def onchange_product(self):
@@ -180,6 +181,7 @@ class StockMove(models.Model):
move.display_assign_serial = (
move.has_tracking == 'serial' and
move.picking_type_id.use_create_lots and
+ not move.picking_type_id.show_reserved and
not move.picking_type_id.use_existing_lots
)
@@ -579,16 +581,20 @@ class StockMove(models.Model):
moves_to_unreserve.mapped('move_line_ids').unlink()
return True
- def _generate_serial_numbers(self):
+ def _generate_serial_numbers(self, next_serial_count=False):
""" This method will generate `lot_name` from a string (field
- `next_serial`) and assign each `lot_name` to a move line.
+ `next_serial`) and create a move line for each generated `lot_name`.
"""
self.ensure_one()
+
+ if not next_serial_count:
+ next_serial_count = self.next_serial_count
# We look if the serial number contains at least one digit.
- caught_initial_number = regex_search("\d+", self.next_serial)
+ caught_initial_number = regex_findall("\d+", self.next_serial)
if not caught_initial_number:
raise UserError(_('The serial number must contain at least one digit.'))
- initial_number = caught_initial_number.group()
+ # We base the serie on the last number find in the base serial number.
+ initial_number = caught_initial_number[-1]
padding = len(initial_number)
# We split the serial number to get the prefix and suffix.
splitted = regex_split(initial_number, self.next_serial)
@@ -596,21 +602,23 @@ class StockMove(models.Model):
suffix = splitted[1]
initial_number = int(initial_number)
- # Then, for each move line without `lot_id` and `lot_name`, we compute
- # and assign the `lot_name` and set the `qty_done` to one.
move_lines_commands = []
- for move_line in self.move_line_ids:
- if not move_line.lot_id and not move_line.lot_name:
- lot_name = '%s%s%s' % (
- prefix,
- str(initial_number).zfill(padding),
- suffix
- )
- initial_number += 1
- move_lines_commands.append((1, move_line.id, {
- 'lot_name': lot_name,
- 'qty_done': 1
- }))
+ location_dest = self.location_dest_id._get_putaway_strategy(self.product_id) or self.location_dest_id
+ for i in range(0, next_serial_count):
+ lot_name = '%s%s%s' % (
+ prefix,
+ str(initial_number + i).zfill(padding),
+ suffix
+ )
+ move_lines_commands.append((0, 0, {
+ 'lot_name': lot_name,
+ 'qty_done': 1,
+ 'product_id': self.product_id.id,
+ 'product_uom_id': self.product_id.uom_id.id,
+ 'location_id': self.location_id.id,
+ 'location_dest_id': location_dest.id,
+ 'picking_id': self.picking_id.id,
+ }))
self.write({'move_line_ids': move_lines_commands})
return True
@@ -955,6 +963,10 @@ class StockMove(models.Model):
)
return vals
+ def _update_next_serial_count(self):
+ self.next_serial = None
+ self.next_serial_count = len(self.move_line_ids)
+
def _update_reserved_quantity(self, need, available_quantity, location_id, lot_id=None, package_id=None, owner_id=None, strict=True):
""" Create or update move lines.
"""
@@ -1134,6 +1146,8 @@ class StockMove(models.Model):
assigned_moves |= move
break
partially_available_moves |= move
+ if move.product_id.tracking == 'serial':
+ move._update_next_serial_count()
partially_available_moves.write({'state': 'partially_available'})
assigned_moves.write({'state': 'assigned'})
self.mapped('picking_id')._check_entire_pack()
diff --git a/addons/stock/tests/test_generate_serial_numbers.py b/addons/stock/tests/test_generate_serial_numbers.py
index f40c8d6f35a..4a4a22734ec 100644
--- a/addons/stock/tests/test_generate_serial_numbers.py
+++ b/addons/stock/tests/test_generate_serial_numbers.py
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo.exceptions import UserError
-from odoo.tests.common import SavepointCase
+from odoo.exceptions import UserError, ValidationError
+from odoo.tests.common import Form, SavepointCase
class StockGenerate(SavepointCase):
@@ -40,6 +40,7 @@ class StockGenerate(SavepointCase):
move_lines_val.append({
'product_id': self.product_serial.id,
'product_uom_id': self.uom_unit.id,
+ 'product_uom_qty': 1,
'location_id': self.location.id,
'location_dest_id': self.location_dest.id
})
@@ -53,28 +54,33 @@ class StockGenerate(SavepointCase):
})
def test_generate_01_sn(self):
- """ Generates a Serial Number for each move lines and checks the
- generated Serial Numbers are what we expect.
+ """ Creates a move with 5 move lines, then asks for generates 5 Serial
+ Numbers. Checks move has 5 new move lines with each a SN, and the 5
+ original move lines are still unchanged.
"""
nbre_of_lines = 5
move = self.get_new_move(nbre_of_lines)
- wiz = self.Wizard.create({
- 'move_id': move.id,
- 'next_serial_number': '001',
- })
- for i in range(nbre_of_lines):
- self.assertEqual(move.move_line_ids[i].qty_done, 0)
+ form_wizard = Form(self.env['stock.assign.serial'].with_context(
+ default_move_id=move.id,
+ default_next_serial_number='001',
+ default_next_serial_count=nbre_of_lines,
+ ))
+ wiz = form_wizard.save()
+ self.assertEqual(len(move.move_line_ids), nbre_of_lines)
wiz.generate_serial_numbers()
- # Checks all move lines have the right SN
+ # Checks new move lines have the right SN
generated_numbers = ['001', '002', '003', '004', '005']
- for i in range(nbre_of_lines):
+ self.assertEqual(len(move.move_line_ids), nbre_of_lines + len(generated_numbers))
+ for move_line in move.move_line_nosuggest_ids:
# For a product tracked by SN, the `qty_done` is set on 1 when
# `lot_name` is set.
- self.assertEqual(move.move_line_ids[i].qty_done, 1)
- self.assertEqual(
- move.move_line_ids[i].lot_name,
- generated_numbers[i])
+ self.assertEqual(move_line.qty_done, 1)
+ self.assertEqual(move_line.lot_name, generated_numbers.pop(0))
+ # Checks pre-generated move lines didn't change
+ for move_line in (move.move_line_ids - move.move_line_nosuggest_ids):
+ self.assertEqual(move_line.qty_done, 0)
+ self.assertEqual(move_line.lot_name, False)
def test_generate_02_prefix_suffix(self):
""" Generates some Serial Numbers and checks the prefix and/or suffix
@@ -83,73 +89,184 @@ class StockGenerate(SavepointCase):
nbre_of_lines = 10
# Case #1: Prefix, no suffix
move = self.get_new_move(nbre_of_lines)
- wiz = self.Wizard.create({
- 'move_id': move.id,
- 'next_serial_number': 'bilou-87',
- })
+ form_wizard = Form(self.env['stock.assign.serial'].with_context(
+ default_move_id=move.id,
+ default_next_serial_number='bilou-87',
+ default_next_serial_count=nbre_of_lines,
+ ))
+ wiz = form_wizard.save()
wiz.generate_serial_numbers()
# Checks all move lines have the right SN
generated_numbers = [
'bilou-87', 'bilou-88', 'bilou-89', 'bilou-90', 'bilou-91',
'bilou-92', 'bilou-93', 'bilou-94', 'bilou-95', 'bilou-96'
]
- for i in range(nbre_of_lines):
+ for move_line in move.move_line_nosuggest_ids:
# For a product tracked by SN, the `qty_done` is set on 1 when
# `lot_name` is set.
- self.assertEqual(move.move_line_ids[i].qty_done, 1)
+ self.assertEqual(move_line.qty_done, 1)
self.assertEqual(
- move.move_line_ids[i].lot_name,
- generated_numbers[i])
+ move_line.lot_name,
+ generated_numbers.pop(0)
+ )
# Case #2: No prefix, suffix
move = self.get_new_move(nbre_of_lines)
- wiz = self.Wizard.create({
- 'move_id': move.id,
- 'next_serial_number': '005-ccc',
- })
+ form_wizard = Form(self.env['stock.assign.serial'].with_context(
+ default_move_id=move.id,
+ default_next_serial_number='005-ccc',
+ default_next_serial_count=nbre_of_lines,
+ ))
+ wiz = form_wizard.save()
wiz.generate_serial_numbers()
# Checks all move lines have the right SN
generated_numbers = [
'005-ccc', '006-ccc', '007-ccc', '008-ccc', '009-ccc',
'010-ccc', '011-ccc', '012-ccc', '013-ccc', '014-ccc'
]
- for i in range(nbre_of_lines):
+ for move_line in move.move_line_nosuggest_ids:
# For a product tracked by SN, the `qty_done` is set on 1 when
# `lot_name` is set.
- self.assertEqual(move.move_line_ids[i].qty_done, 1)
+ self.assertEqual(move_line.qty_done, 1)
self.assertEqual(
- move.move_line_ids[i].lot_name,
- generated_numbers[i])
+ move_line.lot_name,
+ generated_numbers.pop(0)
+ )
- # Case #2: Prefix + suffix
+ # Case #3: Prefix + suffix
move = self.get_new_move(nbre_of_lines)
- wiz = self.Wizard.create({
- 'move_id': move.id,
- 'next_serial_number': 'alpha-012-345-beta',
- })
+ form_wizard = Form(self.env['stock.assign.serial'].with_context(
+ default_move_id=move.id,
+ default_next_serial_number='alpha-012-345-beta',
+ default_next_serial_count=nbre_of_lines,
+ ))
+ wiz = form_wizard.save()
wiz.generate_serial_numbers()
# Checks all move lines have the right SN
generated_numbers = [
- 'alpha-012-345-beta', 'alpha-013-345-beta', 'alpha-014-345-beta',
- 'alpha-015-345-beta', 'alpha-016-345-beta', 'alpha-017-345-beta',
- 'alpha-018-345-beta', 'alpha-019-345-beta', 'alpha-020-345-beta',
- 'alpha-021-345-beta'
+ 'alpha-012-345-beta', 'alpha-012-346-beta', 'alpha-012-347-beta',
+ 'alpha-012-348-beta', 'alpha-012-349-beta', 'alpha-012-350-beta',
+ 'alpha-012-351-beta', 'alpha-012-352-beta', 'alpha-012-353-beta',
+ 'alpha-012-354-beta'
]
- for i in range(nbre_of_lines):
+ for move_line in move.move_line_nosuggest_ids:
# For a product tracked by SN, the `qty_done` is set on 1 when
# `lot_name` is set.
- self.assertEqual(move.move_line_ids[i].qty_done, 1)
+ self.assertEqual(move_line.qty_done, 1)
self.assertEqual(
- move.move_line_ids[i].lot_name,
- generated_numbers[i])
+ move_line.lot_name,
+ generated_numbers.pop(0)
+ )
def test_generate_03_raise_exception(self):
""" Tries to generate some SN but with invalid initial number.
"""
move = self.get_new_move(3)
- wiz = self.Wizard.create({
- 'move_id': move.id,
- 'next_serial_number': 'code-xxx',
- })
+ form_wizard = Form(self.env['stock.assign.serial'].with_context(
+ default_move_id=move.id,
+ default_next_serial_number='code-xxx',
+ ))
+ wiz = form_wizard.save()
with self.assertRaises(UserError):
wiz.generate_serial_numbers()
+
+ form_wizard.next_serial_count = 0
+ # Must raise an exception because `next_serial_count` must be greater than 0.
+ with self.assertRaises(ValidationError):
+ form_wizard.save()
+
+ def test_generate_04_generate_in_multiple_time(self):
+ """ Generates a Serial Number for each move lines (except the last one)
+ but with multiple assignments, and checks the generated Serial Numbers
+ are what we expect.
+ """
+ nbre_of_lines = 10
+ move = self.get_new_move(nbre_of_lines)
+
+ form_wizard = Form(self.env['stock.assign.serial'].with_context(
+ default_move_id=move.id,
+ ))
+ # First assignment
+ form_wizard.next_serial_count = 3
+ form_wizard.next_serial_number = '001'
+ wiz = form_wizard.save()
+ wiz.generate_serial_numbers()
+ # Second assignment
+ form_wizard.next_serial_count = 2
+ form_wizard.next_serial_number = 'bilou-64'
+ wiz = form_wizard.save()
+ wiz.generate_serial_numbers()
+ # Third assignment
+ form_wizard.next_serial_count = 4
+ form_wizard.next_serial_number = 'ro-1337-bot'
+ wiz = form_wizard.save()
+ wiz.generate_serial_numbers()
+
+ # Checks all move lines have the right SN
+ generated_numbers = [
+ # Correspond to the first assignment
+ '001', '002', '003',
+ # Correspond to the second assignment
+ 'bilou-64', 'bilou-65',
+ # Correspond to the third assignment
+ 'ro-1337-bot', 'ro-1338-bot', 'ro-1339-bot', 'ro-1340-bot',
+ ]
+ self.assertEqual(len(move.move_line_ids), nbre_of_lines + len(generated_numbers))
+ self.assertEqual(len(move.move_line_nosuggest_ids), len(generated_numbers))
+ for move_line in move.move_line_nosuggest_ids:
+ self.assertEqual(move_line.qty_done, 1)
+ self.assertEqual(move_line.lot_name, generated_numbers.pop(0))
+ for move_line in (move.move_line_ids - move.move_line_nosuggest_ids):
+ self.assertEqual(move_line.qty_done, 0)
+ self.assertEqual(move_line.lot_name, False)
+
+ def test_generate_with_putaway(self):
+ """ Checks the `location_dest_id` of generated move lines is correclty
+ set in fonction of defined putaway rules.
+ """
+ nbre_of_lines = 4
+ shelf_location = self.env['stock.location'].create({
+ 'name': 'shelf1',
+ 'usage': 'internal',
+ 'location_id': self.location_dest.id,
+ })
+
+ # Checks a first time without putaway...
+ move = self.get_new_move(nbre_of_lines)
+ form_wizard = Form(self.env['stock.assign.serial'].with_context(
+ default_move_id=move.id,
+ ))
+ form_wizard.next_serial_count = nbre_of_lines
+ form_wizard.next_serial_number = '001'
+ wiz = form_wizard.save()
+ wiz.generate_serial_numbers()
+
+ for move_line in move.move_line_nosuggest_ids:
+ self.assertEqual(move_line.qty_done, 1)
+ # The location dest must be the default one.
+ self.assertEqual(move_line.location_dest_id.id, self.location_dest.id)
+
+ # We need to activate multi-locations to use putaway rules.
+ grp_multi_loc = self.env.ref('stock.group_stock_multi_locations')
+ self.env.user.write({'groups_id': [(4, grp_multi_loc.id)]})
+ # Creates a putaway rule
+ putaway_product = self.env['stock.putaway.rule'].create({
+ 'product_id': self.product_serial.id,
+ 'location_in_id': self.location_dest.id,
+ 'location_out_id': shelf_location.id,
+ })
+
+ # Checks now with putaway...
+ move = self.get_new_move(nbre_of_lines)
+ form_wizard = Form(self.env['stock.assign.serial'].with_context(
+ default_move_id=move.id,
+ ))
+ form_wizard.next_serial_count = nbre_of_lines
+ form_wizard.next_serial_number = '001'
+ wiz = form_wizard.save()
+ wiz.generate_serial_numbers()
+
+ for move_line in move.move_line_nosuggest_ids:
+ self.assertEqual(move_line.qty_done, 1)
+ # The location dest must be now the one from the putaway.
+ self.assertEqual(move_line.location_dest_id.id, shelf_location.id)
diff --git a/addons/stock/views/stock_move_views.xml b/addons/stock/views/stock_move_views.xml
index c7c92b62fc2..0c441b731bb 100644
--- a/addons/stock/views/stock_move_views.xml
+++ b/addons/stock/views/stock_move_views.xml
@@ -191,16 +191,17 @@