From 1d675780c2f0f7af110be7d7fc7ccfb2bca99022 Mon Sep 17 00:00:00 2001 From: svs-odoo Date: Tue, 6 Aug 2019 08:43:34 +0000 Subject: [PATCH] [IMP] stock: adapt assign serial numbers In detailed operation view, the method to generate and assign multiple SN numbers will create a move line depending of the SN name the user defined and the number of SN to generate. These features are only accessible if picking option "Pre-fill Detailed Operations" is disabled. Also, hides the quick "Assign Serial Numbers" button in the same case. Task #2028780 --- addons/stock/models/stock_move.py | 54 +++-- .../tests/test_generate_serial_numbers.py | 215 ++++++++++++++---- addons/stock/views/stock_move_views.xml | 7 +- addons/stock/views/stock_picking_views.xml | 3 +- .../wizard/stock_assign_serial_numbers.py | 20 +- .../wizard/stock_assign_serial_views.xml | 4 +- 6 files changed, 226 insertions(+), 77 deletions(-) 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 @@ -