Skip to content
Draft
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/actions/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ def _package_level_parser(self):
"location_id:location_src",
lambda rec, fname: self.location(
fields.first(rec.move_line_ids).location_id
or fields.first(rec.move_lines).location_id
or fields.first(rec.move_ids).location_id
or rec.picking_id.location_id
),
),
Expand Down
4 changes: 4 additions & 0 deletions shopfloor/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@

class ConcurentWorkOnTransfer(Exception):
"""Some user already processed some transfers."""


class CannotProcessMoreThanPlanned(Exception):
"""Cannot process more units than the quantity"""
29 changes: 26 additions & 3 deletions shopfloor/models/stock_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ def split_unavailable_qty(self):
partial_move.split_other_move_lines(partial_move.move_line_ids)
return partial_moves

def _last_move_from_package_level(self):
"""Returns True if self is the last move in the related package level"""
if self.package_level_id.move_ids - self:
# More moves in package level than self
return False
move_lines = self.move_line_ids
if move_lines.package_level_id.move_line_ids - move_lines:
# More lines in package level than there's lines with package level
return False
return True

def _extract_in_split_order(self, default=None, backorder=False):
"""Extract moves in a new picking

Expand Down Expand Up @@ -83,10 +94,22 @@ def _extract_in_split_order(self, default=None, backorder=False):
message = (_("The split order {} has been created.")).format(link)
picking.message_post(body=message)
self.picking_id = new_picking.id
self.package_level_id.picking_id = new_picking.id
self.move_line_ids.picking_id = new_picking.id
self.move_line_ids.package_level_id.picking_id = new_picking.id
self._action_assign()
# When package content is fully extracted to a new picking, also move
# the package. Otherwise the package stays in current picking.
if self._last_move_from_package_level():
self.package_level_id.picking_id = new_picking.id
self.move_line_ids.package_level_id.picking_id = new_picking.id
else:
lines = self.move_line_ids
lines.write({"package_level_id": False})
self.write({"package_level_id": False})

for line in lines:
# We drop result package only if the whole package was
# supposed to be moved.
if line.package_id == line.result_package_id:
line.result_package_id = False
return new_picking

def extract_and_action_done(self):
Expand Down
9 changes: 7 additions & 2 deletions shopfloor/models/stock_move_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from odoo.exceptions import UserError
from odoo.tools.float_utils import float_compare, float_is_zero

from ..exceptions import CannotProcessMoreThanPlanned

_logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -43,8 +45,11 @@ def _split_partial_quantity(self):
)
qty_lesser = compare == -1
qty_greater = compare == 1
assert not qty_greater, "Quantity done cannot exceed quantity to do"
if qty_lesser:
if qty_greater:
raise CannotProcessMoreThanPlanned(
"Quantity done cannot exceed quantity to do"
)
elif qty_lesser:
remaining = self.reserved_uom_qty - self.qty_done
new_line = self.copy({"reserved_uom_qty": remaining, "qty_done": 0})
# if we didn't bypass reservation update, the quant reservation
Expand Down
18 changes: 8 additions & 10 deletions shopfloor/services/zone_picking.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component

from ..exceptions import ConcurentWorkOnTransfer
from ..exceptions import CannotProcessMoreThanPlanned, ConcurentWorkOnTransfer
from ..utils import to_float


Expand Down Expand Up @@ -1033,15 +1033,6 @@ def _set_destination_package(self, move_line, quantity, package):
return (package_changed, response)
# the quantity done is set to the passed quantity
# but if we move a partial qty, we need to split the move line
compare = self._move_line_compare_qty(move_line, quantity)
qty_greater = compare == 1
if qty_greater:
response = self._response_for_set_line_destination(
move_line,
message=self.msg_store.unable_to_pick_more(move_line.reserved_uom_qty),
qty_done=quantity,
)
return (package_changed, response)
stock = self._actions_for("stock")
stock._lock_lines(move_line)
try:
Expand All @@ -1058,6 +1049,13 @@ def _set_destination_package(self, move_line, quantity, package):
qty_done=quantity,
)
return (package_changed, response)
except CannotProcessMoreThanPlanned:
response = self._response_for_set_line_destination(
move_line,
message=self.msg_store.unable_to_pick_more(move_line.quantity),
qty_done=quantity,
)
return (package_changed, response)
package_changed = True
# Zero check
# Only apply zero check if the product is of type "product".
Expand Down
1 change: 1 addition & 0 deletions shopfloor/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
from . import test_misc
from . import test_move_action_assign
from . import test_scan_anything
from . import test_stock_move
from . import test_stock_split
from . import test_picking_form
from . import test_user
111 changes: 111 additions & 0 deletions shopfloor/tests/model_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Copyright 2026 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

from odoo.tests import Form, tagged
from odoo.tests.common import TransactionCase


@tagged("-at_install", "post_install")
class ModelCommon(TransactionCase):
"""Common class to test model code"""

@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
cls.setUpClassData()

@classmethod
def setUpClassData(cls):
cls.warehouse = cls.env.ref("stock.warehouse0")
cls.customer_location = cls.env.ref("stock.stock_location_customers")
cls.pack_location = cls.warehouse.wh_pack_stock_loc_id
cls.ship_location = cls.warehouse.wh_output_stock_loc_id
cls.stock_location = cls.env.ref("stock.stock_location_stock")
# Create products
cls.product_a = (
cls.env["product.product"]
.sudo()
.create(
{
"name": "Product A",
"type": "product",
"default_code": "A",
"barcode": "A",
"weight": 2,
}
)
)
cls.product_a_packaging = (
cls.env["product.packaging"]
.sudo()
.create(
{
"name": "Box",
"product_id": cls.product_a.id,
"barcode": "ProductABox",
}
)
)
cls.product_b = (
cls.env["product.product"]
.sudo()
.create(
{
"name": "Product B",
"type": "product",
"default_code": "B",
"barcode": "B",
"weight": 2,
}
)
)
cls.product_b_packaging = (
cls.env["product.packaging"]
.sudo()
.create(
{
"name": "Box",
"product_id": cls.product_b.id,
"barcode": "ProductBBox",
}
)
)
# Put product_a quantities in different packages to get several move lines
cls.package_1 = cls.env["stock.quant.package"].create({"name": "PACKAGE_1"})
cls.package_2 = cls.env["stock.quant.package"].create({"name": "PACKAGE_2"})
cls.package_3 = cls.env["stock.quant.package"].create({"name": "PACKAGE_3"})
cls.package_4 = cls.env["stock.quant.package"].create({"name": "PACKAGE_4"})

@classmethod
def _update_qty_in_location(
cls, location, product, quantity, package=None, lot=None
):
quants = cls.env["stock.quant"]._gather(
product, location, lot_id=lot, package_id=package, strict=True
)
# this method adds the quantity to the current quantity, so remove it
quantity -= sum(quants.mapped("quantity"))
if not quantity:
return
cls.env["stock.quant"]._update_available_quantity(
product, location, quantity=quantity, package_id=package, lot_id=lot
)

@classmethod
def _create_picking(cls, picking_type=None, lines=None, confirm=True, **kw):
picking_form = Form(cls.env["stock.picking"])
picking_form.picking_type_id = picking_type or cls.picking_type
picking_form.partner_id = cls.customer
if lines is None:
lines = [(cls.product_a, 10), (cls.product_b, 10)]
for product, qty in lines:
with picking_form.move_ids_without_package.new() as move:
move.product_id = product
move.product_uom_qty = qty
for k, v in kw.items():
setattr(picking_form, k, v)
picking = picking_form.save()
if confirm:
picking.action_confirm()
return picking
91 changes: 91 additions & 0 deletions shopfloor/tests/test_stock_move.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Copyright 2026 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
from .model_common import ModelCommon


class TestStockMove(ModelCommon):
@classmethod
def setUpClassData(cls):
res = super().setUpClassData()
cls.picking_type = cls.env.ref("stock.picking_type_internal")
cls.customer = cls.env["res.partner"].create({"name": "Bob"})
return res

def test_extract_in_split_order_partial(self):
package = self.package_1
# multiple moves in the same package, one move split, package stays
self._update_qty_in_location(
self.stock_location, self.product_a, 10.0, package=package
)
self._update_qty_in_location(
self.stock_location, self.product_b, 10.0, package=package
)
picking = self._create_picking(confirm=True)
move1 = picking.move_ids[0]
move2 = picking.move_ids[1]
move_line1 = move1.move_line_ids
move_line2 = move2.move_line_ids
package_level = picking.package_level_ids
self.assertEqual(len(package_level), 1)
self.assertEqual(package_level, move_line1.package_level_id)
self.assertEqual(package_level, move_line2.package_level_id)
split_picking = move2._extract_in_split_order()
# extracting move2 is taking half the content of the package.
# Package level remains on picking.
self.assertEqual(picking.package_level_ids, package_level)
self.assertEqual(move_line1.package_level_id, package_level)
self.assertEqual(move_line1.result_package_id, package)
# move2 has been extracted in a new picking,
# which shouldn't have a package level
self.assertFalse(split_picking.package_level_ids)
self.assertFalse(move_line2.package_level_id)
self.assertFalse(move_line2.result_package_id)

def test_extract_in_split_order_full(self):
package = self.package_1
# multiple moves in the same package, one move split, package stays
self._update_qty_in_location(
self.stock_location,
self.product_a,
10.0,
)
self._update_qty_in_location(
self.stock_location, self.product_b, 10.0, package=package
)
picking = self._create_picking(confirm=True)
move1 = picking.move_ids[0]
move2 = picking.move_ids[1] # productb
move_line1 = move1.move_line_ids
move_line2 = move2.move_line_ids
package_level = picking.package_level_ids
self.assertEqual(len(package_level), 1)
self.assertFalse(move_line1.package_level_id)
self.assertEqual(package_level, move_line2.package_level_id)
split_picking = move2._extract_in_split_order()
# extracting move2 is taking the full package.
# Package level is moved in the new picking
self.assertFalse(picking.package_level_ids)
self.assertFalse(move_line1.package_level_id)
self.assertFalse(move_line1.result_package_id)
# move2 has been extracted in a new picking, and the package with it
self.assertEqual(split_picking.package_level_ids, package_level)
self.assertEqual(move_line2.package_level_id, package_level)
self.assertEqual(move_line2.result_package_id, package)

def test_last_move_from_package_level(self):
package = self.package_1
# multiple moves in the same package, one move split, package stays
self._update_qty_in_location(
self.stock_location, self.product_a, 10.0, package=package
)
self._update_qty_in_location(
self.stock_location, self.product_b, 10.0, package=package
)
picking = self._create_picking(confirm=True)
move1 = picking.move_ids[0]
move2 = picking.move_ids[1]
# Both move1 and move2 are in the package level, so either of them are the last
self.assertFalse(move1._last_move_from_package_level())
self.assertFalse(move2._last_move_from_package_level())
# But together, they represent the last moves of the package level
self.assertTrue((move1 | move2)._last_move_from_package_level())
Loading
Loading