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
74 changes: 69 additions & 5 deletions stock_full_location_reservation/models/stock_move.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
import logging
import warnings
from typing import Literal

from odoo import api, fields, models, tools

_logger = logging.getLogger(__name__)


class StockMove(models.Model):
Expand All @@ -10,6 +16,15 @@ class StockMove(models.Model):
"Full location reservation move", default=False
)

def init(self):
tools.create_index(
self._cr,
"stock_move_full_loc_reservation_gc_idx",
self._table,
["id"],
where="is_full_location_reservation AND state = 'cancel'",
)

def _filter_full_location_reservation_moves(self):
return self.filtered(lambda m: m.is_full_location_reservation)

Expand All @@ -30,7 +45,38 @@ def _undo_full_location_reservation(self):
self = self.with_context(skip_undo_full_location_reservation=True)
self._do_unreserve()
self._action_cancel()
self.unlink()
self._post_commit_unlink()

def _post_commit_unlink(self):
# We need to defer the unlink of the moves until the end of the
# transaction to avoid issues where methods having a reference
# to the original move would try to access it after it has been unlinked
ids_to_unlink = self.ids
env = self.env

def _deferred_unlink():
try:
env["stock.move"].browse(ids_to_unlink).exists().unlink()
except Exception:
_logger.exception(
"Failed to unlink full location reservation moves %s",
ids_to_unlink,
)

self.env.cr.postcommit.add(_deferred_unlink)

@api.autovacuum
def _gc_full_location_reservation_moves(self):
"""This method is meant to be called by a cron and will delete full location
reservation moves that are still in canceled state.

This should normally not be necessary as the moves should be unlinked right
after being canceled into the post commit hook of the transaction,
but this is a safety measure in case something goes wrong with the unlinking.
"""
self.search(
[("is_full_location_reservation", "=", True), ("state", "=", "cancel")]
).unlink()

def _prepare_full_location_reservation_package_level_vals(self, package):
return {
Expand Down Expand Up @@ -72,7 +118,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
Comment thread
lmignon marked this conversation as resolved.
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 +135,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
Loading
Loading