[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 <amoyaux@users.noreply.github.com>
This commit is contained in:
svs-odoo
2019-06-24 12:56:02 +00:00
committed by Arnold Moyaux
parent 0e69cd996d
commit 3520220268
10 changed files with 296 additions and 0 deletions
+1
View File
@@ -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',
+68
View File
@@ -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
+1
View File
@@ -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',
+1
View File
@@ -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
@@ -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()
+10
View File
@@ -175,6 +175,7 @@
<field name="picking_id" invisible="1"/>
<field name="is_locked" invisible="1"/>
<field name="picking_type_entire_packs" invisible="1"/>
<field name="display_assign_serial" invisible="1"/>
<group>
<group>
<field name="product_id" readonly="1"/>
@@ -190,6 +191,15 @@
<span><field name="reserved_availability" nolabel="1" attrs="{'invisible': [('state', '=', 'done')]}" /></span>
<span><field name="product_uom" readonly="1" nolabel="1"/></span>
</div>
<label for="next_serial" attrs="{'invisible': [('display_assign_serial', '=', False)]}"/>
<div class="o_row" attrs="{'invisible': [('display_assign_serial', '=', False)]}">
<span><field name="next_serial"/></span>
<button name="action_assign_serial_show_details" type="object"
class="btn-link"
title="Assign Serial Numbers">
<span>Assign Serial Numbers</span>
</button>
</div>
</group>
</group>
@@ -321,6 +321,8 @@
<field name="has_move_lines" invisible="1"/>
<field name="is_locked" invisible="1"/>
<field name="product_uom_category_id" invisible="1"/>
<field name="has_tracking" invisible="1"/>
<field name="display_assign_serial" invisible="1"/>
<field name="product_id" required="1" attrs="{'readonly': ['|', '&amp;', ('state', '!=', 'draft'), ('additional', '=', False), ('has_move_lines', '=', True)]}"/>
<field name="description_picking" string="Description"/>
<field name="is_initial_demand_editable" invisible="1"/>
@@ -330,6 +332,13 @@
<field name="quantity_done" string="Done" attrs="{'readonly': [('is_quantity_done_editable', '=', False)]}"/>
<field name="product_uom" attrs="{'readonly': [('state', '!=', 'draft'), ('additional', '=', False)]}" options="{'no_open': True, 'no_create': True}" string="Unit of Measure" groups="uom.group_uom"/>
<button name="action_show_details" string="Register lots, packs, location" type="object" icon="fa-list" attrs="{'invisible': [('show_details_visible', '=', False)]}" options='{"warn": true}'/>
<button name="action_assign_serial" type="object"
class="btn-sm btn-primary fa fa-plus-square-o"
style="line-height: 1;"
role="img" title="Assign Serial Numbers"
attrs="{'invisible': ['|',
('display_assign_serial', '=', False),
('parent.show_operations', '=', False)]}"/>
</tree>
</field>
<field name="package_level_ids" context="{'default_location_id': location_id, 'default_location_dest_id': location_dest_id}" attrs="{'readonly': [('state', '=', 'done')], 'invisible': ['|', ('picking_type_entire_packs', '=', False), ('show_operations', '=', True)]}" />
+1
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import stock_assign_serial_numbers
from . import stock_picking_return
from . import stock_scheduler_compute
from . import stock_immediate_transfer
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class StockAssignSerialNumbers(models.TransientModel):
_name = 'stock.assign.serial'
_description = 'Stock Assign Serial Numbers'
product_id = fields.Many2one('product.product', 'Product',
related='move_id.product_id', required=True)
move_id = fields.Many2one('stock.move', required=True)
next_serial_number = fields.Char('Next Serial Number')
def generate_serial_numbers(self):
self.ensure_one()
self.move_id.next_serial = self.next_serial_number or ""
return self.move_id._generate_serial_numbers()
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_assign_serial_numbers" model="ir.ui.view">
<field name="name">stock_assign_serial_numbers</field>
<field name="model">stock.assign.serial</field>
<field name="arch" type="xml">
<form string="Assign Serial Numbers">
<group>
<field name="product_id" readonly="1"/>
<field name="next_serial_number" required="1"/>
</group>
<footer>
<button name="generate_serial_numbers" type="object"
string="Assign Serial Numbers" class="oe_highlight"/>
<button special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
<record id="act_assign_serial_numbers" model="ir.actions.act_window">
<field name="name">Assign Serial Numbers</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">stock.assign.serial</field>
<field name="view_mode">form</field>
<field name="context">{}</field>
<field name="target">new</field>
</record>
</odoo>