[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
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -191,16 +191,17 @@
|
||||
<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)]}"/>
|
||||
<field name="next_serial"
|
||||
attrs="{'invisible': [('display_assign_serial', '=', False)]}"/>
|
||||
<label for="next_serial_count" attrs="{'invisible': [('display_assign_serial', '=', False)]}"/>
|
||||
<div class="o_row" attrs="{'invisible': [('display_assign_serial', '=', False)]}">
|
||||
<span><field name="next_serial"/></span>
|
||||
<span><field name="next_serial_count"/></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>
|
||||
<field name="move_line_ids" attrs="{'readonly': ['|', ('state', '=', 'cancel'), '&', ('state', '=', 'done'), ('is_locked', '=', True)]}" context="{'tree_view_ref': 'stock.view_stock_move_line_operation_tree', 'default_product_uom_id': product_uom, 'default_picking_id': picking_id, 'default_move_id': id, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id}"/>
|
||||
|
||||
@@ -328,8 +328,9 @@
|
||||
class="btn-sm btn-primary fa fa-plus-square-o"
|
||||
style="line-height: 1;"
|
||||
role="img" title="Assign Serial Numbers"
|
||||
attrs="{'invisible': ['|',
|
||||
attrs="{'invisible': ['|', '|',
|
||||
('display_assign_serial', '=', False),
|
||||
('parent.show_reserved', '=', False),
|
||||
('parent.show_operations', '=', False)]}"/>
|
||||
</tree>
|
||||
</field>
|
||||
|
||||
@@ -3,19 +3,33 @@
|
||||
import re
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class StockAssignSerialNumbers(models.TransientModel):
|
||||
_name = 'stock.assign.serial'
|
||||
_description = 'Stock Assign Serial Numbers'
|
||||
|
||||
def _default_next_serial_count(self):
|
||||
move = self.env['stock.move'].browse(self.env.context.get('default_move_id'))
|
||||
if move.exists():
|
||||
filtered_move_lines = move.move_line_ids.filtered(lambda l: not l.lot_name and not l.lot_id)
|
||||
return len(filtered_move_lines)
|
||||
|
||||
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')
|
||||
next_serial_number = fields.Char('First SN', required=True)
|
||||
next_serial_count = fields.Integer('Number of SN',
|
||||
default=_default_next_serial_count, required=True)
|
||||
|
||||
@api.constrains('next_serial_count')
|
||||
def _check_next_serial_count(self):
|
||||
for wizard in self:
|
||||
if wizard.next_serial_count < 1:
|
||||
raise ValidationError(_("The number of Serial Numbers to generate must greater than zero."))
|
||||
|
||||
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()
|
||||
return self.move_id._generate_serial_numbers(next_serial_count=self.next_serial_count)
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="Assign Serial Numbers">
|
||||
<group>
|
||||
<field name="move_id" invisible="1"/>
|
||||
<field name="product_id" readonly="1"/>
|
||||
<field name="next_serial_number" required="1"/>
|
||||
<field name="next_serial_number"/>
|
||||
<field name="next_serial_count"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="generate_serial_numbers" type="object"
|
||||
|
||||
Reference in New Issue
Block a user