From 35202202687f6292261ad01a3958df20bcf15264 Mon Sep 17 00:00:00 2001 From: svs-odoo Date: Tue, 23 Apr 2019 06:15:00 +0000 Subject: [PATCH] [IMP] stock: generate multiple SN/LN for SML Adds a button in stock move form view to generate multiple serial number and assigns them to stock move lines. Through a wizard, the user enter the first SN. Then, starting with this number, iterates to assign and increment the SN on each move line who doesn't have already one. Task #1832645 closes odoo/odoo#33466 Signed-off-by: Arnold Moyaux --- addons/stock/__manifest__.py | 1 + addons/stock/models/stock_move.py | 68 ++++++++ addons/stock/models/stock_picking.py | 1 + addons/stock/tests/__init__.py | 1 + .../tests/test_generate_serial_numbers.py | 155 ++++++++++++++++++ addons/stock/views/stock_move_views.xml | 10 ++ addons/stock/views/stock_picking_views.xml | 9 + addons/stock/wizard/__init__.py | 1 + .../wizard/stock_assign_serial_numbers.py | 21 +++ .../wizard/stock_assign_serial_views.xml | 29 ++++ 10 files changed, 296 insertions(+) create mode 100644 addons/stock/tests/test_generate_serial_numbers.py create mode 100644 addons/stock/wizard/stock_assign_serial_numbers.py create mode 100644 addons/stock/wizard/stock_assign_serial_views.xml diff --git a/addons/stock/__manifest__.py b/addons/stock/__manifest__.py index 900e92ef6bb..99457e06d98 100644 --- a/addons/stock/__manifest__.py +++ b/addons/stock/__manifest__.py @@ -40,6 +40,7 @@ 'report/product_templates.xml', 'report/product_packaging.xml', + 'wizard/stock_assign_serial_views.xml', 'wizard/stock_picking_return_views.xml', 'wizard/stock_scheduler_compute_views.xml', 'wizard/stock_immediate_transfer_views.xml', diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index e16e56da5d4..c8b65853d90 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -5,6 +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 odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp @@ -164,12 +165,23 @@ class StockMove(models.Model): has_move_lines = fields.Boolean(compute='_compute_has_move_lines') 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') @api.onchange('product_id', 'picking_type_id') def onchange_product(self): if self.product_id: self.description_picking = self.product_id._get_description(self.picking_type_id) + @api.depends('has_tracking', 'picking_type_id.use_create_lots', 'picking_type_id.use_existing_lots') + def _compute_display_assign_serial(self): + for move in self: + move.display_assign_serial = ( + move.has_tracking == 'serial' and + move.picking_type_id.use_create_lots and + not move.picking_type_id.use_existing_lots + ) + @api.depends('picking_id.is_locked') def _compute_is_locked(self): for move in self: @@ -471,6 +483,27 @@ class StockMove(models.Model): ), } + def action_assign_serial_show_details(self): + """ On `self.move_line_ids`, assign `lot_name` according to + `self.next_serial` before returning `self.action_show_details`. + """ + self.ensure_one() + if not self.next_serial: + raise UserError(_("You need to set a Serial Number before generating more.")) + self._generate_serial_numbers() + return self.action_show_details() + + def action_assign_serial(self): + """ Opens a wizard to assign SN's name on each move lines. + """ + self.ensure_one() + action = self.env.ref('stock.act_assign_serial_numbers').read()[0] + action['context'] = { + 'default_product_id': self.product_id.id, + 'default_move_id': self.id, + } + return action + def _do_unreserve(self): moves_to_unreserve = self.env['stock.move'] for move in self: @@ -487,6 +520,41 @@ class StockMove(models.Model): moves_to_unreserve.mapped('move_line_ids').unlink() return True + def _generate_serial_numbers(self): + """ This method will generate `lot_name` from a string (field + `next_serial`) and assign each `lot_name` to a move line. + """ + self.ensure_one() + # We look if the serial number contains at least one digit. + caught_initial_number = regex_search("\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() + padding = len(initial_number) + # We split the serial number to get the prefix and suffix. + splitted = regex_split(initial_number, self.next_serial) + prefix = splitted[0] + 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 + })) + self.write({'move_line_ids': move_lines_commands}) + return True + def _push_apply(self): for move in self: # if the move is already chained, there is no need to check push rules diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 561ae614d99..d2699c95382 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -289,6 +289,7 @@ class Picking(models.Model): show_validate = fields.Boolean( compute='_compute_show_validate', help='Technical field used to compute whether the validate should be shown.') + use_create_lots = fields.Boolean(related='picking_type_id.use_create_lots') owner_id = fields.Many2one( 'res.partner', 'Owner', diff --git a/addons/stock/tests/__init__.py b/addons/stock/tests/__init__.py index 043b70ca572..baccccf1f6e 100644 --- a/addons/stock/tests/__init__.py +++ b/addons/stock/tests/__init__.py @@ -6,6 +6,7 @@ from . import test_warehouse from . import test_stock_location_search from . import test_quant from . import test_quant_inventory_mode +from . import test_generate_serial_numbers from . import test_inventory from . import test_move from . import test_move2 diff --git a/addons/stock/tests/test_generate_serial_numbers.py b/addons/stock/tests/test_generate_serial_numbers.py new file mode 100644 index 00000000000..f40c8d6f35a --- /dev/null +++ b/addons/stock/tests/test_generate_serial_numbers.py @@ -0,0 +1,155 @@ +# -*- 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 + + +class StockGenerate(SavepointCase): + @classmethod + def setUpClass(cls): + super(StockGenerate, cls).setUpClass() + Product = cls.env['product.product'] + cls.product_serial = Product.create({ + 'name': 'Tracked by SN', + 'type': 'product', + 'tracking': 'serial', + }) + cls.uom_unit = cls.env.ref('uom.product_uom_unit') + + warehouse = cls.env['stock.warehouse'].create({ + 'name': 'Base Warehouse', + 'reception_steps': 'one_step', + 'delivery_steps': 'ship_only', + 'code': 'BWH' + }) + cls.location = cls.env['stock.location'].create({ + 'name': 'Room A', + 'location_id': warehouse.lot_stock_id.id, + }) + cls.location_dest = cls.env['stock.location'].create({ + 'name': 'Room B', + 'location_id': warehouse.lot_stock_id.id, + }) + + cls.Wizard = cls.env['stock.assign.serial'] + + def get_new_move(self, nbre_of_lines): + move_lines_val = [] + for i in range(nbre_of_lines): + move_lines_val.append({ + 'product_id': self.product_serial.id, + 'product_uom_id': self.uom_unit.id, + 'location_id': self.location.id, + 'location_dest_id': self.location_dest.id + }) + return self.env['stock.move'].create({ + 'name': 'Move Test', + 'product_id': self.product_serial.id, + 'product_uom': self.uom_unit.id, + 'location_id': self.location.id, + 'location_dest_id': self.location_dest.id, + 'move_line_ids': [(0, 0, line_vals) for line_vals in move_lines_val] + }) + + def test_generate_01_sn(self): + """ Generates a Serial Number for each move lines and checks the + generated Serial Numbers are what we expect. + """ + 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) + wiz.generate_serial_numbers() + # Checks all move lines have the right SN + generated_numbers = ['001', '002', '003', '004', '005'] + for i in range(nbre_of_lines): + # 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]) + + def test_generate_02_prefix_suffix(self): + """ Generates some Serial Numbers and checks the prefix and/or suffix + are correctly used. + """ + 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', + }) + 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 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]) + + # 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', + }) + 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 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]) + + # Case #2: 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', + }) + 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' + ] + for i in range(nbre_of_lines): + # 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]) + + 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', + }) + with self.assertRaises(UserError): + wiz.generate_serial_numbers() diff --git a/addons/stock/views/stock_move_views.xml b/addons/stock/views/stock_move_views.xml index f34a7c39973..0a0f9586e11 100644 --- a/addons/stock/views/stock_move_views.xml +++ b/addons/stock/views/stock_move_views.xml @@ -175,6 +175,7 @@ + @@ -190,6 +191,15 @@ + diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index c8aad236faa..32dca9c4242 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -321,6 +321,8 @@ + + @@ -330,6 +332,13 @@