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 stock_full_location_reservation/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
{
"name": "Stock full location reservation",
"summary": "Extend reservation to full content of location",
"author": "MT Software, BCIM, Odoo Community Association (OCA)",
"author": "MT Software, BCIM, ACSONE SA/NV, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/wms",
"category": "Warehouse Management",
"version": "16.0.1.0.0",
Expand Down
27 changes: 24 additions & 3 deletions stock_full_location_reservation/models/stock_move.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import warnings
from typing import Literal

from odoo import fields, models


Expand Down Expand Up @@ -72,7 +75,10 @@ def _full_location_reservation_create_move(
product, qty, location, package
)
)
if self.picking_type_id.merge_move_for_full_location_reservation:
if (
self.product_id == product
and self.picking_type_id.merge_move_for_full_location_reservation
):
# To be able to be merged, the new move should use the same source location as
# the original one.
new_move.location_id = self.location_id
Expand All @@ -86,5 +92,20 @@ def _full_location_reservation_create_move(
)
return new_move

def _full_location_reservation(self, package_only=None):
return self.move_line_ids._full_location_reservation(package_only)
def _full_location_reservation(
self,
reservation_mode: Literal["strict", "package", "product"] | None = None,
**kwargs
):
if "package_only" in kwargs:
warnings.warn(
"The 'package_only' parameter is deprecated. "
"Use reservation_mode='package' instead.",
DeprecationWarning,
stacklevel=2,
)
if kwargs.pop("package_only"):
reservation_mode = "package"
return self.move_line_ids._full_location_reservation(
reservation_mode=reservation_mode
)
117 changes: 106 additions & 11 deletions stock_full_location_reservation/models/stock_move_line.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Copyright 2026 ACSONE SA/NV <htts://www.acsone.eu>
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import warnings
from collections import defaultdict
from typing import Literal

from odoo import models
from odoo.osv import expression
Expand All @@ -10,7 +13,9 @@
class StockMoveLine(models.Model):
_inherit = "stock.move.line"

def _prepare_full_location_reservation_quants_domain(self, package_only=None):
def _prepare_full_location_reservation_quants_domain(
self, package_only=None, product_only=False
):
domains = []
for line in self:
domain = [("location_id", "=", line.location_id.id)]
Expand All @@ -19,15 +24,25 @@ def _prepare_full_location_reservation_quants_domain(self, package_only=None):
domain += [("package_id", "=", line.package_id.id)]
else:
continue
if product_only:
domain += [("product_id", "=", line.product_id.id)]
domains.append(domain)
return expression.OR(domains)

def _get_full_location_reservation_quants(self, package_only=None):
domain = self._prepare_full_location_reservation_quants_domain(package_only)
def _get_full_location_reservation_quants(
self, package_only=None, product_only=False
):
domain = self._prepare_full_location_reservation_quants_domain(
package_only=package_only, product_only=product_only
)
return self.env["stock.quant"].search(domain)

def _get_full_location_reservable_qties(self, package_only=None):
quants = self._get_full_location_reservation_quants(package_only)
def _get_full_location_reservable_qties(
self, package_only=None, product_only=False
):
quants = self._get_full_location_reservation_quants(
package_only=package_only, product_only=product_only
)
res = defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: 0)))
for quant in quants:
qty_available = quant.available_quantity
Expand All @@ -42,24 +57,104 @@ def _get_full_location_reservable_qties(self, package_only=None):
] += qty_available
return res

def _full_location_reservation(self, package_only=None):
reservable_qties = self._get_full_location_reservable_qties(package_only)
moves_to_assign_ids = []
def _full_location_reservation_strict(self):
"""Reserve using Odoo's _gather with strict=True (exact lot/package/owner match).

Increments reserved_uom_qty on existing move lines - no new moves created.
"""
Quant = self.env["stock.quant"]
for line in self.exists(): # Move line should have been deleted
# Copy location and package as move line could be deleted if merge occurs
quants = Quant._gather(
line.product_id,
line.location_id,
lot_id=line.lot_id,
package_id=line.package_id,
owner_id=line.owner_id,
strict=True,
)
if not quants:
continue
# We let the core mechanism occur that will reserve the needed quants
line.reserved_uom_qty += sum(q.available_quantity for q in quants)

def _full_location_reservation_product(self):
"""Reserve all available qty of each line's product across every package
at the location - creates one new move per (package, product) combination.
"""
move_ids = []
reservable_qties = self._get_full_location_reservable_qties(product_only=True)
for line in self.exists():
location_qties = reservable_qties.get(line.location_id, {})
for package in list(location_qties.keys()):
package_qties = location_qties[package]
qty = package_qties.pop(line.product_id, 0)
if not package_qties:
location_qties.pop(package)
if qty:
move_ids.append(
line.move_id._full_location_reservation_create_move(
line.product_id, qty, line.location_id, package
).id
)
return move_ids

def _full_location_reservation_by_location(self, package_only=False):
"""Shared implementation: creates one new move per (location, package, product)
combination found in reservable quantities.
"""
move_ids = []
reservable_qties = self._get_full_location_reservable_qties(
package_only=package_only
)
for line in self.exists():
location = line.location_id
package = line.package_id
qties = reservable_qties.get(location, {}).get(package, {})
if not qties:
continue
for product, qty in qties.items():
moves_to_assign_ids.append(
move_ids.append(
line.move_id._full_location_reservation_create_move(
product, qty, location, package
).id
)
reservable_qties[location].pop(package)
moves_to_assign = self.env["stock.move"].browse(moves_to_assign_ids)
return move_ids

def _full_location_reservation_package(self):
"""Reserve all products at each line's (location, package),
skipping lines that have no package.
"""
return self._full_location_reservation_by_location(package_only=True)

def _full_location_reservation_default(self):
"""Reserve all products at each line's (location, package)."""
return self._full_location_reservation_by_location()

def _full_location_reservation(
self,
reservation_mode: Literal["strict", "package", "product"] | None = None,
**kwargs,
):
if "package_only" in kwargs:
warnings.warn(
"The 'package_only' parameter is deprecated. "
"Use reservation_mode='package' instead.",
DeprecationWarning,
stacklevel=2,
)
if kwargs.pop("package_only"):
reservation_mode = "package"
if reservation_mode == "strict":
self._full_location_reservation_strict()
return self.env["stock.move"]
elif reservation_mode == "product":
move_ids = self._full_location_reservation_product()
elif reservation_mode == "package":
move_ids = self._full_location_reservation_package()
else:
move_ids = self._full_location_reservation_default()
moves_to_assign = self.env["stock.move"].browse(move_ids)
if moves_to_assign:
moves_to_assign._action_confirm()
moves_to_assign._action_assign()
Expand Down
1 change: 1 addition & 0 deletions stock_full_location_reservation/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
* Michael Tietz (MT Software) <mtietz@mt-software.de>
* Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
* Denis Roussel <denis.roussel@acsone.eu>
* Laurent Mignon <laurent.mignon@acsone.eu>
6 changes: 6 additions & 0 deletions stock_full_location_reservation/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ def setUpClass(cls):
cls.location_rack_child = cls.location.create(
{"name": "Rack child", "location_id": cls.location_rack.id}
)
cls.location_rack_child_2 = cls.location.create(
{"name": "Rack child 2", "location_id": cls.location_rack.id}
)
cls.location_rack_child_3 = cls.location.create(
{"name": "Rack child 3", "location_id": cls.location_rack.id}
)
cls.customer_location = cls.env.ref("stock.stock_location_customers")

def _create_quant(self, product, location, qty, package=None):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,72 @@ def test_full_location_reservation(self):
self._check_move_line_len(picking, 1)
self._check_move_line_len(picking, 0, self._filter_func)

def test_multi_lines_per_move(self):
"""
We create :

- Quantity of 10 on Rack of Product A
- Quantity of 10 on Rack 2 of Product A
- Quantity of 10 on Rack 3 of Product A

We create a picking of 30 from parent Rack, all quantities should
be reserved.

Then, we update the Rack to 60 and we launch the full reservation
on move 1.

The moves lines should remains, the reserved total quantity should
be 80 when the original demand should remain.

"""
self.picking_type.merge_move_for_full_location_reservation = True
self._create_quants(
[
(self.productA, self.location_rack_child, 10.0),
(self.productA, self.location_rack_child_2, 10.0),
(self.productA, self.location_rack_child_3, 10.0),
(self.productB, self.location_rack_child, 15.0),
]
)
picking = self._create_picking(
self.location_rack,
self.customer_location,
self.picking_type,
[[self.productA, 30]],
)
picking.action_confirm()
picking.action_assign()
self.assertEqual(1, len(picking.move_ids))
move_line_ids = picking.move_line_ids
self.assertEqual(3, len(picking.move_line_ids))

self._create_quants(
[
(self.productA, self.location_rack_child, 50.0),
]
)

self.assertEqual(
60.0,
self.productA.with_context(
location=self.location_rack_child.id
).qty_available,
)

picking.move_line_ids[0]._full_location_reservation(reservation_mode="strict")

self.assertEqual(3, len(picking.move_line_ids))

self.assertEqual(
move_line_ids,
picking.move_line_ids,
)
self.assertEqual(picking.move_line_ids[0].reserved_uom_qty, 60.0)

# The original demand stays at 30.0
self.assertEqual(picking.move_ids.product_uom_qty, 30.0)
self.assertEqual(picking.move_ids.reserved_availability, 80.0)

def test_multiple_pickings(self):
picking = self._create_picking(
self.location_rack,
Expand Down Expand Up @@ -104,11 +170,82 @@ def test_package_only(self):

self.assertEqual(picking.move_line_ids.package_id, package)
self._check_move_line_len(picking, 2)
picking.move_ids._full_location_reservation(package_only=True)
picking.move_ids._full_location_reservation(reservation_mode="package")
self._check_move_line_len(picking, 3)
self.assertEqual(picking.move_line_ids.package_id, package)
self.assertEqual(sum(picking.move_line_ids.mapped("reserved_qty")), 11)

def test_product_mode(self):
"""
Location has productA and productB.
After product mode on a productA move: only productA gets a new
full-reservation move — productB is left untouched.
"""
self._create_quants(
[
(self.productA, self.location_rack_child, 10.0),
(self.productB, self.location_rack_child, 10.0),
]
)
picking = self._create_picking(
self.location_rack,
self.customer_location,
self.picking_type,
[[self.productA, 1]],
)
picking.action_confirm()
picking.action_assign()

self._check_move_line_len(picking, 1)
picking.move_ids._full_location_reservation(reservation_mode="product")
# Original move + 1 new full-reservation move for remaining productA
self._check_move_line_len(picking, 2)
self._check_move_line_len(picking, 1, self._filter_func)
full_reservation_moves = picking.move_ids.filtered(self._filter_func)
self.assertEqual(full_reservation_moves.product_id, self.productA)
# Total reserved = all 10 units of productA; productB untouched
self.assertEqual(
sum(picking.move_line_ids.mapped("reserved_uom_qty")),
10.0,
)

def test_product_mode_multiple_packages(self):
"""
Location has productA in two package combinations (packaged and
unpackaged) plus productB. Move for productA (no package).
After product mode: new moves are created for each package combination
of productA — productB remains untouched.
"""
package = self.env["stock.quant.package"].create({"name": "test package"})
self._create_quants(
[
(self.productA, self.location_rack_child, 5.0),
(self.productA, self.location_rack_child, 7.0, package),
(self.productB, self.location_rack_child, 10.0),
]
)
picking = self._create_picking(
self.location_rack,
self.customer_location,
self.picking_type,
[[self.productA, 1]],
)
picking.action_confirm()
picking.action_assign()

self._check_move_line_len(picking, 1)
picking.move_ids._full_location_reservation(reservation_mode="product")
# Original move + 2 new full-reservation moves (one per package combo)
self._check_move_line_len(picking, 3)
self._check_move_line_len(picking, 2, self._filter_func)
full_reservation_moves = picking.move_ids.filtered(self._filter_func)
self.assertEqual(full_reservation_moves.product_id, self.productA)
# Total reserved: 1 (original) + 4 (remaining no-pkg) + 7 (packaged) = 12
self.assertEqual(
sum(picking.move_line_ids.mapped("reserved_uom_qty")),
12.0,
)

def test_full_location_reservation_merge(self):
"""
Activate the merge for new quantity move.
Expand Down Expand Up @@ -141,5 +278,6 @@ def test_full_location_reservation_merge(self):
picking.do_full_location_reservation()

self._check_move_line_len(picking, 1)
# The original demand remains the same
self.assertEqual(10.0, picking.move_ids.product_uom_qty)
self.assertEqual(10.0, picking.move_ids.reserved_availability)
Loading