Skip to content
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
2 changes: 1 addition & 1 deletion shopfloor/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
"version": "16.0.2.24.0",
"version": "16.0.2.24.1",
"development_status": "Beta",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
Expand Down
5 changes: 4 additions & 1 deletion shopfloor/actions/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,10 @@ def lines_different_dest_location(self):
def new_move_lines_not_assigned(self):
return {
"message_type": "error",
"body": _("New move lines cannot be assigned: canceled."),
"body": _(
"Some products in the source location are already reserved. "
"It's impossible to reserve them!"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
"It's impossible to reserve them!"
"It's impossible to reserve them."

I would avoid the exclamation mark in messages returned to the user?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, less pressure but we know they don't read them at all 😃

),
}

def package_open(self):
Expand Down
3 changes: 2 additions & 1 deletion shopfloor/data/shopfloor_scenario_data.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@
"no_prefill_qty": true,
"allow_move_line_search_sort_order": true,
"allow_move_line_search_additional_domain": true,
"allow_quantity_exceeding_demand": true
"allow_quantity_exceeding_demand": true,
"allow_reserve_only_available": true

}
</field>
Expand Down
25 changes: 25 additions & 0 deletions shopfloor/migrations/16.0.2.24.1/post-migrate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import json
import logging

from odoo import SUPERUSER_ID, api

_logger = logging.getLogger(__name__)


def migrate(cr, version):
if not version:
return
env = api.Environment(cr, SUPERUSER_ID, {})
location_content_transfer = env.ref("shopfloor.scenario_location_content_transfer")
_update_scenario_options(location_content_transfer)


def _update_scenario_options(scenario):
options = scenario.options
options["allow_reserve_only_available"] = True
options_edit = json.dumps(options or {}, indent=4, sort_keys=True)
scenario.write({"options_edit": options_edit})
_logger.info(
"Option 'allow_reserve_only_available' added to scenario %s",
scenario.name,
)
14 changes: 14 additions & 0 deletions shopfloor/models/shopfloor_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ class ShopfloorMenu(models.Model):
help="If you tick this box, this scenario will allow operator to move"
" goods even if a reservation is made by a different operation type.",
)
reserve_only_available_is_possible = fields.Boolean(
compute="_compute_reserve_only_available_is_possible"
)
allow_reserve_only_available = fields.Boolean(
string="Allow to reserve only available quantities on source location",
Comment on lines +87 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

As with location content transfer we want to move the goods, is "reserve" the right word here? Suggestion:

Suggested change
allow_reserve_only_available = fields.Boolean(
string="Allow to reserve only available quantities on source location",
allow_moving_only_available = fields.Boolean(
string="Allow to move only available quantities on source location",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

In fact, this is for quant quantities reservations, the 'move' is the next step :-)

help="Check this if you want the scenario to reserve only available quantities",
)
ignore_no_putaway_available_is_possible = fields.Boolean(
compute="_compute_ignore_no_putaway_available_is_possible"
)
Expand Down Expand Up @@ -337,6 +344,13 @@ def _check_options(self):
)
)

@api.depends("scenario_id", "picking_type_ids")
def _compute_reserve_only_available_is_possible(self):
for menu in self:
menu.reserve_only_available_is_possible = bool(
menu.scenario_id.has_option("allow_reserve_only_available")
)

@api.depends("scenario_id", "picking_type_ids")
def _compute_move_create_is_possible(self):
for menu in self:
Expand Down
17 changes: 16 additions & 1 deletion shopfloor/services/location_content_transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _
from odoo.fields import first
from odoo.tools import float_compare

from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
Expand Down Expand Up @@ -218,14 +219,28 @@ def _create_moves_from_location(self, location):
# create moves for each quant
picking_type = self.picking_types
move_vals_list = []
allow_reserve_only_available = self.work.menu.allow_reserve_only_available
for quant in quants:
if (
allow_reserve_only_available
and not float_compare(
quant.available_quantity,
0.0,
precision_rounding=quant.product_uom_id.rounding,
)
> 0
):
# Don't take fully reserved quants
continue
move_vals_list.append(
{
"name": quant.product_id.name,
"company_id": picking_type.company_id.id,
"product_id": quant.product_id.id,
"product_uom": quant.product_uom_id.id,
"product_uom_qty": quant.quantity,
"product_uom_qty": quant.available_quantity
if allow_reserve_only_available
else quant.quantity,
"location_id": location.id,
"location_dest_id": picking_type.default_location_dest_id.id,
"origin": self.work.menu.name,
Expand Down
152 changes: 152 additions & 0 deletions shopfloor/tests/test_location_content_transfer_mix.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,158 @@ def test_with_zone_picking2(self):
self.assertEqual(picking_before, picking_after)
self.assert_response_scan_destination_all(response, picking_after)

def test_with_zone_picking2_only_available(self):
"""Test the following scenario:

1) Operator-1 processes the first pallet with the "zone picking" scenario
to move the goods to PACK-1 and unload in destination location1:

move1 PICK -> PACK-1 'done'

2) Operator-1 processes the second pallet with the "zone picking" scenario
to move the goods to PACK-2 and unload in destination location2:

move1 PICK -> PACK-2 'done'

3) Operator-2 with the "location content transfer" scenario scan
the location where the first pallet is (PACK-1):
- the app should found one move line
- this move line will be put in its own transfer as its sibling lines
are in another source location
- as such the app should ask the destination location (as there is
only one line)

move1 PACK-2 -> SHIP (still handled by the operator so not 'done')

4) Operator-3 with the "location content transfer" scenario scan
the location where the first pallet is (PACK-1):
- nothing is found as the pallet is currently handled by Operator-2
- message to user should be the location is empty

5) If Operator-2 is unable to finish the flow with the first pallet
(barcode device out of battery... etc), he should be able to recover
what he started.

6) Operator-2 then finishes its operation regarding the first pallet, and
scan the location where the second pallet is (PACK-2). He should find
only this pallet available.
"""
self.menu.sudo().allow_reserve_only_available = True
move_lines = self.picking1.move_line_ids
pick_move_line1 = move_lines.filtered(
lambda ml: ml.result_package_id == self.package_1
)
pick_move_line2 = move_lines.filtered(
lambda ml: ml.result_package_id == self.package_2
)
# Operator-1 process the first pallet with the "zone picking" scenario
orig_dest_location = pick_move_line1.location_dest_id
dest_location1 = pick_move_line1.location_dest_id.sudo().copy(
{
"name": orig_dest_location.name + "_1",
"barcode": orig_dest_location.barcode + "_1",
"location_id": orig_dest_location.id,
}
)
self._zone_picking_process_line(pick_move_line1, dest_location=dest_location1)
# Operator-1 process the second pallet with the "zone picking" scenario
dest_location2 = orig_dest_location.sudo().copy(
{
"name": orig_dest_location.name + "_2",
"barcode": orig_dest_location.barcode + "_2",
"location_id": orig_dest_location.id,
}
)
self._zone_picking_process_line(pick_move_line2, dest_location=dest_location2)
pack_move_a = pick_move_line1.move_id.move_dest_ids.filtered(
lambda m: m.state not in ("cancel", "done")
)
self.assertEqual(pack_move_a, self.pack_move_a)
pack_first_pallet = pack_move_a.move_line_ids.filtered(
lambda x: not x.shopfloor_user_id and x.location_id == dest_location1
)
self.assertEqual(pack_first_pallet.reserved_uom_qty, 6)
self.assertEqual(pack_first_pallet.qty_done, 0)
pack_second_pallet = pack_move_a.move_line_ids.filtered(
lambda x: not x.shopfloor_user_id and x.location_id == dest_location2
)
self.assertEqual(pack_second_pallet.reserved_uom_qty, 4)
self.assertEqual(pack_second_pallet.qty_done, 0)
# Operator-2 with the "location content transfer" scenario scan
# the location where the first pallet is.
# This pallet/move line will be put in its own transfer as its sibling
# lines are in another source location.
previous_picking = pack_first_pallet.picking_id
response = self._location_content_transfer_process_line(pack_first_pallet)
new_picking = pack_first_pallet.picking_id
self.assertTrue(previous_picking != new_picking)
self.assert_response_scan_destination_all(response, new_picking)
response_packages = response["data"]["scan_destination_all"]["package_levels"]
self.assertEqual(len(response_packages), 1)
self.assertEqual(
response_packages[0]["package_src"]["id"], pack_first_pallet.package_id.id
)
# Ensure that the second pallet is untouched
self.assertEqual(pack_second_pallet.qty_done, 0)
# Operator-3 with the "location content transfer" scenario scan
# the location where the first pallet is: he should found nothing
response = self._location_content_transfer_process_line(
pack_first_pallet, user=self.stock_user2
)
self.assert_response_start(
response,
message=self.service.msg_store.location_empty(
pack_first_pallet.location_id
),
)
# Check if Operator-2 is able to recover its session
expected_picking = pack_first_pallet.picking_id
response = self.service.start_or_recover()
self.assert_response_scan_destination_all(
response,
expected_picking,
message=self.service.msg_store.recovered_previous_session(),
)
# Operator-2 finishes its operation regarding the first pallet
qty = pack_first_pallet.reserved_uom_qty
response = self.service.set_destination_all(
pack_first_pallet.location_id.id, pack_first_pallet.location_dest_id.barcode
)
self.assert_response_start(
response,
message=self.service.msg_store.location_content_transfer_complete(
pack_first_pallet.location_id,
pack_first_pallet.location_dest_id,
),
)
self.assertEqual(pack_first_pallet.qty_done, 6)
self.assertEqual(pack_first_pallet.state, "done")
self.assertEqual(pack_first_pallet.move_id.product_uom_qty, qty)
# Ensure that the second pallet is untouched
self.assertEqual(pack_second_pallet.qty_done, 0)
# Operator-2 (still with the "location content transfer" scenario) scan
# the location where the second pallet is
pack_move_a = pick_move_line2.move_id.move_dest_ids.filtered(
lambda m: m.state not in ("cancel", "done")
)
self.assertEqual(pack_move_a, self.pack_move_a)
pack_second_pallet = pack_move_a.move_line_ids.filtered(
lambda x: not x.shopfloor_user_id and x.location_id == dest_location2
)
picking_before = pack_second_pallet.picking_id
move_lines = self.service.search_move_line.search_move_lines(
locations=pack_second_pallet.location_id
)
response = self._location_content_transfer_process_line(pack_second_pallet)
response_packages = response["data"]["scan_destination_all"]["package_levels"]
self.assertEqual(len(response_packages), 1)
self.assertEqual(
response_packages[0]["package_src"]["id"], pack_second_pallet.package_id.id
)
picking_after = pack_second_pallet.picking_id
self.assertEqual(picking_before, picking_after)
self.assert_response_scan_destination_all(response, picking_after)

def test_with_zone_picking3(self):
"""Test the following scenario:

Expand Down
73 changes: 73 additions & 0 deletions shopfloor/tests/test_location_content_transfer_start.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,3 +380,76 @@ def test_scan_location_create_moves(self):
self.product_a | self.product_b | self.product_c | self.product_d,
)
self.assertEqual(picking.state, "assigned")

def test_scan_location_create_moves_partially_available(self):
"""The scanned location has no move lines but has some quants to move."""
picking_type = self.menu.picking_type_ids
# product_a alone
self.env["stock.quant"]._update_available_quantity(
self.product_a,
self.content_loc,
10,
)

self.env["stock.quant"]._update_available_quantity(
self.product_b, self.content_loc, 10
)
self.env["stock.quant"]._update_available_quantity(
self.product_c, self.content_loc, 5
)
self.env["stock.quant"]._update_available_quantity(
self.product_d, self.content_loc, 5
)

# Create a move that reserve partially product b
move = self.env["stock.move"].create(
{
"product_id": self.product_b.id,
"name": self.product_b.name,
"product_uom_qty": 3.0,
"location_id": self.content_loc.id,
"location_dest_id": self.stock_location.id,
}
)
move._action_confirm()
move._action_assign()

response = self.service.dispatch(
"scan_location", params={"barcode": self.content_loc.barcode}
)
self.assert_response(
response,
"scan_location",
message=self.service.msg_store.new_move_lines_not_assigned(),
)
picking = self.env["stock.picking"].search(
[("picking_type_id", "=", picking_type.id)]
)
self.assertEqual(len(picking), 0)

self.menu.sudo().allow_reserve_only_available = True

response = self.service.dispatch(
"scan_location", params={"barcode": self.content_loc.barcode}
)
picking = self.env["stock.picking"].search(
[("picking_type_id", "=", picking_type.id)]
)

self.assert_response_scan_destination_all(response, picking)
move_line_id = response["data"]["scan_destination_all"]["move_lines"][0]["id"]
package_levels = response["data"]["scan_destination_all"]["package_levels"]
self.assertFalse(package_levels)
self.assertIn(move_line_id, picking.move_line_ids.ids)

self.assertEqual(picking.state, "assigned")

# Check the product b has 7.0 of reserved quantity
line_b_id = [
line["id"]
for line in response["data"]["scan_destination_all"]["move_lines"]
if line["product"]["id"] == self.product_b.id
]
line_b = self.env["stock.move.line"].browse(line_b_id)
self.assertTrue(line_b)
self.assertEqual(7.0, line_b.reserved_qty)
8 changes: 8 additions & 0 deletions shopfloor/views/shopfloor_menu.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@
<field name="move_create_is_possible" invisible="1" />
<field name="allow_move_create" />
</group>
<group
name="allow_reserve_only_available"
attrs="{'invisible': [('reserve_only_available_is_possible', '=', False)]}"
>
<field name="reserve_only_available_is_possible" invisible="1" />
<field name="allow_reserve_only_available" />
</group>

<group
name="unreserve_other_moves"
attrs="{'invisible': [('unreserve_other_moves_is_possible', '=', False)]}"
Expand Down
Loading