[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:
svs-odoo
2019-08-13 09:07:48 +00:00
parent a831e01464
commit 1d675780c2
6 changed files with 226 additions and 77 deletions
+34 -20
View File
@@ -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)
+4 -3
View File
@@ -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'), '&amp;', ('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}"/>
+2 -1
View File
@@ -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"