[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:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
@@ -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': ['|', '&', ('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,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>
|
||||
Reference in New Issue
Block a user