Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[14.0][IMP] shopfloor cluster picking: Add option to sort processed move lines #964

Open
wants to merge 2 commits into
base: 14.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions shopfloor/migrations/14.0.5.0.0/post-migration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright 2024 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
import json

from openupgradelib import openupgrade


@openupgrade.migrate()
def migrate(env, version):
scenario = env.ref("shopfloor.scenario_cluster_picking")
options = scenario.options
options["allow_move_line_processing_sort_order"] = True
scenario.write(
{"options_edit": json.dumps(options or {}, indent=4, sort_keys=True)}
)
62 changes: 62 additions & 0 deletions shopfloor/models/shopfloor_menu.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _, api, exceptions, fields, models
from odoo.tools.safe_eval import test_python_expr

PICK_PACK_SAME_TIME_HELP = """
If you tick this box, while picking goods from a location
Expand Down Expand Up @@ -53,6 +54,30 @@
to scan a destination package.
"""

MOVE_LINE_PROCESSING_SORT_ORDER_HELP = (
"By priority & location:\n"
"> each line is sorted by priority and then by location to perform a smart path in"
" the warehouse.\n"
"By location grouped by product:\n"
"> in case of multiple move lines for the same product, break the sorting to"
" finalize a started product by processing all other move lines for that product"
" in order to group a product on a picking device. When stacking products on a"
" pallet, this prevents to spread a same product at different level on the stack."
)

CUSTOM_CODE_DEFAULT = (
"# Available variables:\n"
"# - env: Odoo Environment on which the action is triggered\n"
"# - time, datetime, dateutil, timezone: useful Python libraries\n"
"# - records: lines to sort\n"
"# - default_filter_func: default filtering function\n"
"#\n"
"# To provide the order for stock.move.line records to be processed, define eg.:\n"
"# move_lines = records.filtered(default_filter_func).sorted()\n"
"\n"
"\n"
)


class ShopfloorMenu(models.Model):
_inherit = "shopfloor.menu"
Expand Down Expand Up @@ -226,6 +251,26 @@ class ShopfloorMenu(models.Model):
allow_alternative_destination_package_is_possible = fields.Boolean(
compute="_compute_allow_alternative_destination_package_is_possible"
)
move_line_processing_sort_order_is_possible = fields.Boolean(
compute="_compute_move_line_processing_sort_order_is_possible"
)
move_line_processing_sort_order = fields.Selection(
selection=[
("location", "Location"),
("location_grouped_product", "Location Grouping products"),
("custom_code", "Custom code"),
],
string="Sort method used when processing move lines",
default="location",
required=True,
jbaudoux marked this conversation as resolved.
Show resolved Hide resolved
help=MOVE_LINE_PROCESSING_SORT_ORDER_HELP,
)

move_line_processing_sort_order_custom_code = fields.Text(
string="Custom sort key code",
default=CUSTOM_CODE_DEFAULT,
help="Python code to sort move lines.",
)

@api.onchange("unload_package_at_destination")
def _onchange_unload_package_at_destination(self):
Expand Down Expand Up @@ -455,3 +500,20 @@ def _compute_allow_alternative_destination_package_is_possible(self):
menu.allow_alternative_destination_package_is_possible = (
menu.scenario_id.has_option("allow_alternative_destination_package")
)

@api.depends("scenario_id")
def _compute_move_line_processing_sort_order_is_possible(self):
for menu in self:
menu.move_line_processing_sort_order_is_possible = (
menu.scenario_id.has_option("allow_move_line_processing_sort_order")
)

@api.constrains("move_line_processing_sort_order_custom_code")
def _check_python_code(self):
for menu in self.sudo().filtered("move_line_processing_sort_order_custom_code"):
msg = test_python_expr(
expr=menu.move_line_processing_sort_order_custom_code.strip(),
mode="exec",
)
if msg:
raise exceptions.ValidationError(msg)
93 changes: 82 additions & 11 deletions shopfloor/services/cluster_picking.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@
# Copyright 2020-2022 Jacques-Etienne Baudoux (BCIM) <[email protected]>
# Copyright 2023 Michael Tietz (MT Software) <[email protected]>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _, fields
from pytz import timezone

from odoo import _, exceptions, fields
from odoo.osv import expression
from odoo.tools.safe_eval import (
datetime as safe_datetime,
dateutil as safe_dateutil,
safe_eval,
time as safe_time,
)

from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
Expand Down Expand Up @@ -362,17 +370,80 @@ def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x):
# after ALL the other lines in the batch are processed.
return lines.sorted(key=self._sort_key_lines)

@staticmethod
def _lines_filtering(line):
return (
line.state in ("assigned", "partially_available")
# On 'StockPicking.action_assign()', result_package_id is set to
# the same package as 'package_id'. Here, we need to exclude lines
# that were already put into a bin, i.e. the destination package
# is different.
and (
not line.result_package_id or line.result_package_id == line.package_id
)
)

def _group_by_product(self, lines):
grouped_line_ids = []
product_ids_checked = set()
for line in lines:
if line.product_id.id not in product_ids_checked:
same_product_line_ids = lines.filtered(
lambda x: x.product_id == line.product_id
).ids
grouped_line_ids.extend(same_product_line_ids)
product_ids_checked.add(line.product_id.id)
lines = self.env["stock.move.line"].browse(grouped_line_ids)
return lines

def _lines_to_pick(self, picking_batch):
return self._lines_for_picking_batch(
picking_batch,
filter_func=lambda l: (
l.state in ("assigned", "partially_available")
# On 'StockPicking.action_assign()', result_package_id is set to
# the same package as 'package_id'. Here, we need to exclude lines
# that were already put into a bin, i.e. the destination package
# is different.
and (not l.result_package_id or l.result_package_id == l.package_id)
),
order = self.work.menu.move_line_processing_sort_order
if order == "location":
lines = self._lines_for_picking_batch(
picking_batch, filter_func=self._lines_filtering
)
elif order == "location_grouped_product":
# we need to call _lines_for_picking_batch
# without passing a filter_func so that the ordering is computed
# taking into account all lines in the batch,
# including those that have already been processed.
lines = self._lines_for_picking_batch(
picking_batch,
filter_func=lambda x: x,
)
lines = self._group_by_product(lines).filtered(self._lines_filtering)
elif order == "custom_code":
lines = self._eval_custom_code(picking_batch)
return lines

def _eval_context(self, move_lines):
return {
"uid": self.env.uid,
"user": self.env.user,
"time": safe_time,
"datetime": safe_datetime,
"dateutil": safe_dateutil,
"timezone": timezone,
# orm
"env": self.env,
# record
"records": move_lines,
# filter
"default_filter_func": self._lines_filtering,
}

def _eval_custom_code(self, picking_batch):
expr = self.work.menu.move_line_processing_sort_order_custom_code
move_lines = picking_batch.mapped("picking_ids.move_line_ids")
eval_context = self._eval_context(move_lines)
try:
safe_eval(expr, eval_context, mode="exec", nocopy=True)
except Exception as err:
raise exceptions.UserError(
_("Error when evaluating the move lines sorting code:\n %s") % (err)
)
return eval_context.get(
"move_lines", move_lines.filtered(self._lines_filtering)
)

def _last_picked_line(self, picking):
Expand Down
100 changes: 100 additions & 0 deletions shopfloor/tests/test_cluster_picking_sort_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Copyright 2023 Camptocamp SA (https://www.camptocamp.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).

from odoo import fields
from odoo.tests import tagged

from .test_cluster_picking_base import ClusterPickingCommonCase


@tagged("post_install", "-at_install")
class ClusterPickingSortOrder(ClusterPickingCommonCase):
@classmethod
def setUpClassBaseData(cls, *args, **kwargs):
super().setUpClassBaseData(*args, **kwargs)
cls.batch = cls._create_picking_batch(
[
[cls.BatchProduct(product=cls.product_a, quantity=1)],
[cls.BatchProduct(product=cls.product_b, quantity=1)],
[cls.BatchProduct(product=cls.product_d, quantity=1)],
[cls.BatchProduct(product=cls.product_c, quantity=1)],
[cls.BatchProduct(product=cls.product_b, quantity=1)],
]
)
cls._simulate_batch_selected(cls.batch, in_package=True)
cls.menu.sudo().move_line_processing_sort_order = "location_grouped_product"
return

def test_custom_lines_order(self):
Comment on lines +25 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
cls.menu.sudo().move_line_processing_sort_order = "location_grouped_product"
return
def test_custom_lines_order(self):
def test_location_grouped_product_sorting(self):
self.menu.sudo().move_line_processing_sort_order = "location_grouped_product"

"""The sorting of the lines in the batch groups lines with the same product"""
batch = self.batch

expected_lines_order = self._assign_different_locations(batch)

for expected_line in expected_lines_order:
# We are going to call this empoint once per line
# to simulate the fact that the lines have to be treated
# one at a time.
response = self.service.dispatch(
"confirm_start",
params={"picking_batch_id": batch.id},
)
returned_line = batch.move_line_ids.filtered(
lambda line: line.id == response["data"]["start_line"]["id"]
)
self.assertEqual(returned_line.id, expected_line.id)
returned_line.state = "confirmed"

def _assign_different_locations(self, batch):
# We assign one unique location to each line of the batch
# and we make sure each location has the sequence required for the test.
locations = self.env["stock.location"].search([], limit=5)
# The line with product A will have the lowest sequence.
line_product_a = batch.move_line_ids.filtered(
lambda line: line.product_id == self.product_a
)
line_product_a.location_id = locations[0]
line_product_a.location_id.shopfloor_picking_sequence = 1
line_product_a.state = "assigned"

# One of the lines with product B will have the second lowest sequence.
line_product_b_1 = fields.first(
batch.move_line_ids.filtered(lambda line: line.product_id == self.product_b)
)
line_product_b_1.location_id = locations[1]
line_product_b_1.location_id.shopfloor_picking_sequence = 2
line_product_b_1.state = "assigned"

# The line with product D will have the third lowest sequence.
line_product_d = batch.move_line_ids.filtered(
lambda line: line.product_id == self.product_d
)
line_product_d.location_id = locations[2]
line_product_d.location_id.shopfloor_picking_sequence = 3
line_product_d.state = "assigned"

# The line with product C will have the second highest sequence.
line_product_c = batch.move_line_ids.filtered(
lambda line: line.product_id == self.product_c
)
line_product_c.location_id = locations[3]
line_product_c.location_id.shopfloor_picking_sequence = 4
line_product_c.state = "assigned"

# The other line with product B will have the highest sequence.
line_product_b_2 = batch.move_line_ids.filtered(
lambda line: line.product_id == self.product_b
and line.location_id != locations[1]
)
line_product_b_2.location_id = locations[4]
line_product_b_2.location_id.shopfloor_picking_sequence = 5
line_product_b_2.state = "assigned"

# Return the lines in the order we expect once we sort by product.
return (
line_product_a,
line_product_b_1,
line_product_b_2,
line_product_d,
line_product_c,
)
20 changes: 20 additions & 0 deletions shopfloor/views/shopfloor_menu.xml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,26 @@
/>
<field name="allow_alternative_destination_package" />
</group>
<group
name="move_line_processing_sort_order"
attrs="{'invisible': [('move_line_processing_sort_order_is_possible', '=', False)]}"
>
<field
name="move_line_processing_sort_order_is_possible"
invisible="1"
/>
<field name="move_line_processing_sort_order" />
</group>
</group>
<group name="options" position="after">
<group name="move_line_processing_sort_order_custom_code">
<field
name="move_line_processing_sort_order_custom_code"
attrs="{'invisible': [('move_line_processing_sort_order', '!=', 'custom_code')]}"
widget="ace"
options="{'mode': 'python'}"
/>
</group>
</group>
</field>
</record>
Expand Down
Loading