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

[IMP] stock_reserve_area: add possibility to search stock.move.reserve.area.line records … #16

Open
wants to merge 3 commits into
base: 14.0-add-stock_reservation_area
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
1 change: 1 addition & 0 deletions setup/stock_reserve_area/odoo/addons/stock_reserve_area
6 changes: 6 additions & 0 deletions setup/stock_reserve_area/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
2 changes: 2 additions & 0 deletions stock_reserve_area/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import models
from .hooks import post_init_hook
25 changes: 25 additions & 0 deletions stock_reserve_area/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Copyright 2023 ForgeFlow S.L.
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Stock Reservation Area",
"summary": "Stock reservations on areas (group of locations)",
"version": "14.0.1.0.0",
"author": "ForgeFlow, Odoo Community Association (OCA)",
"category": "Warehouse",
"license": "AGPL-3",
"complexity": "normal",
"website": "https://github.com/OCA/stock-logistics-warehouse",
"depends": ["stock"],
"data": [
"security/ir.model.access.csv",
"security/stock_reserve_area_security.xml",
"views/stock_reserve_area_views.xml",
"views/stock_move_reserve_area_line_views.xml",
"views/stock_location_views.xml",
"views/stock_move_views.xml",
"views/stock_picking_views.xml",
],
"auto_install": False,
"installable": True,
"post_init_hook": "post_init_hook",
}
26 changes: 26 additions & 0 deletions stock_reserve_area/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copyright 2023 ForgeFlow SL.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from odoo import SUPERUSER_ID, api


def post_init_hook(cr, registry):
"""
This post-init-hook will create a Reserve Area for each existing WH.
"""
env = api.Environment(cr, SUPERUSER_ID, dict())
warehouse_obj = env["stock.warehouse"]
warehouses = warehouse_obj.search([])
reserve_area_obj = env["stock.reserve.area"]
for warehouse_id in warehouses.ids:
warehouse = warehouse_obj.browse(warehouse_id)
all_locations = env["stock.location"].search(
[("id", "child_of", warehouse.view_location_id.id)]
)
reserve_area_obj.create(
{
"name": warehouse.name,
"location_ids": [(6, 0, all_locations.ids)],
"company_id": warehouse.company_id.id,
}
)
8 changes: 8 additions & 0 deletions stock_reserve_area/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from . import stock_reserve_area
from . import stock_move
from . import stock_quant
from . import stock_location
from . import stock_picking
from . import stock_warehouse
from . import stock_move_line
from . import stock_move_reserve_area_line
47 changes: 47 additions & 0 deletions stock_reserve_area/models/stock_location.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Copyright 2023 ForgeFlow SL.
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import api, fields, models


class StockLocation(models.Model):
_inherit = "stock.location"

reserve_area_ids = fields.Many2many(
"stock.reserve.area",
relation="stock_reserve_area_stock_location_rel",
column1="location_id",
column2="reserve_area_id",
readonly=True,
)

def write(self, vals):
res = super().write(vals)
return res

@api.depends("reserve_area_ids")
def _update_impacted_moves(self, vals):
"""If a location is moved outside/inside an area we have to check stock_moves"""
if vals.get("reserve_area_ids"):
new_reserve_areas = (
self.env["stock.reserve.area"].sudo().browse(vals["reserve_area_ids"])
)
moves_poss_impacted = self.search(
[
"|",
("location_id", "=", self),
("location_dest_id", "=", self),
("state", "in", ("confirmed", "waiting", "partially_available")),
]
)
for move in moves_poss_impacted:
for reserve_area in new_reserve_areas:
if move._is_out_area(reserve_area):
move.reserve_area_line_ids += self.env[
"stock.move.reserve.area.line"
].create(
{
"move_id": move.id,
"reserve_area_id": reserve_area.id,
}
)
224 changes: 224 additions & 0 deletions stock_reserve_area/models/stock_move.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# Copyright 2023 ForgeFlow SL.
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
from odoo.tools import OrderedSet, float_compare, float_is_zero, float_round


class StockMove(models.Model):
_inherit = "stock.move"

reserve_area_line_ids = fields.One2many("stock.move.reserve.area.line", "move_id")
reserve_area_ids = fields.Many2many(
"stock.reserve.area", compute="_compute_reserve_area_ids", store=True
)
area_reserved_availability = fields.Float(
string="Reserved in Area",
digits="Product Unit of Measure",
readonly=True,
copy=False,
help="Quantity that has been reserved in all reserve"
" Areas of the source location.",
compute="_compute_area_reserved_availability", # minimum of area's reserved
store=True,
)

@api.depends("reserve_area_line_ids.reserved_availability")
def _compute_area_reserved_availability(self):
for move in self:
if move.reserve_area_line_ids:
move.area_reserved_availability = min(
move.reserve_area_line_ids.mapped("reserved_availability")
)
else:
move.area_reserved_availability = 0

@api.depends("location_id")
def _compute_reserve_area_ids(self):
loc_to_area_map = dict()
for location in self.mapped("location_id"):
reserve_areas = self.env["stock.reserve.area"].search([])
for reserve_area in reserve_areas:
if reserve_area.is_location_in_area(location):
if not loc_to_area_map.get(location.id):
loc_to_area_map[location.id] = reserve_area
else:
loc_to_area_map[location.id] |= reserve_area
for move in self:
move.reserve_area_ids = loc_to_area_map.get(move.location_id.id)

def _is_out_area(self, reserve_area_id):
# out of area = true if source location in area and dest location outside
for move in self:
if not reserve_area_id.is_location_in_area(
move.location_dest_id
) and reserve_area_id.is_location_in_area(move.location_id):
return True
return False

def create_reserve_area_lines(self):
line_ids = self.reserve_area_line_ids
for reserve_area in self.reserve_area_ids:
if self._is_out_area(reserve_area) and not self.env[
"stock.move.reserve.area.line"
].search(
[("move_id", "=", self.id), ("reserve_area_id", "=", reserve_area.id)]
):
line_ids += self.env["stock.move.reserve.area.line"].create(
{
"move_id": self.id,
"reserve_area_id": reserve_area.id,
}
)
return line_ids

def _action_area_assign(self):
for move in self.filtered(
lambda m: m.state in ["confirmed", "waiting", "partially_available"]
and m.reserve_area_line_ids
):
move.reserve_area_line_ids._action_area_assign()

def _action_assign(self):
for move in self.filtered(
lambda m: m.state in ["confirmed", "waiting", "partially_available"]
):
move.reserve_area_line_ids = move.create_reserve_area_lines()
self._action_area_assign() # new method to assign globally
super()._action_assign()

def _get_available_quantity(
self,
location_id,
lot_id=None,
package_id=None,
owner_id=None,
strict=False,
allow_negative=False,
):
local_available = super()._get_available_quantity(
location_id,
lot_id=lot_id,
package_id=package_id,
owner_id=owner_id,
strict=strict,
allow_negative=allow_negative,
)
if self.reserve_area_line_ids:
return min(local_available, self.area_reserved_availability)
return local_available

def _do_area_unreserve(self):
# we will delete area_reserve_line_ids from the elegible moves
moves_to_unreserve = OrderedSet()
for move in self:
if (
move.state == "cancel"
or (move.state == "done" and move.scrapped)
or not move.reserve_area_line_ids
):
# We may have cancelled move in an open picking in a
# "propagate_cancel" scenario.
# We may have done move in an open picking in a scrap scenario.
continue
moves_to_unreserve.add(move.id)
self.env["stock.move"].browse(moves_to_unreserve).mapped(
"reserve_area_line_ids"
).unlink()

def _do_unreserve(self):
super()._do_unreserve()
self._do_area_unreserve()
return True

def _action_done(self, cancel_backorder=False):
res = super()._action_done(cancel_backorder)
res.reserve_area_line_ids.unlink()
return res

def _free_reservation_area(self, product_id, reserve_area_id, quantity):
area_available_quantity = self.env[
"stock.quant"
]._get_reserve_area_available_quantity(product_id, reserve_area_id)
if quantity > area_available_quantity:
outdated_move_domain = [
("state", "not in", ["done", "cancel"]),
("product_id", "=", product_id.id),
("reserve_area_ids", "in", reserve_area_id.id),
]
# We take the pickings with the latest scheduled date
outdated_candidates = (
self.env["stock.move"]
.search(outdated_move_domain)
.sorted(
lambda cand: (
-cand.picking_id.scheduled_date.timestamp()
if cand.picking_id
else -cand.id,
)
)
)
# As the move's state is not computed over the move lines, we'll have to manually
# recompute the moves which we adapted their lines.
move_to_recompute_state = self

for candidate in outdated_candidates:
rounding = candidate.product_uom.rounding
quantity_uom = product_id.uom_id._compute_quantity(
quantity, candidate.product_uom, rounding_method="HALF-UP"
)
reserve_area_line = self.env["stock.move.reserve.area.line"].search(
[
("move_id", "=", candidate.id),
("reserve_area_id", "=", reserve_area_id.id),
("reserved_availability", ">", 0.0),
]
)
if reserve_area_line:
if (
float_compare(
reserve_area_line.reserved_availability,
quantity_uom,
precision_rounding=rounding,
)
<= 0
):
quantity_uom -= reserve_area_line.reserved_availability
reserve_area_line.reserved_availability = 0
move_to_recompute_state |= candidate
if float_is_zero(quantity_uom, precision_rounding=rounding):
break
else:
# split this move line and assign the new part to our extra move
quantity_left = float_round(
reserve_area_line.reserved_availability - quantity_uom,
precision_rounding=rounding,
rounding_method="UP",
)
reserve_area_line.reserved_availability = quantity_left
move_to_recompute_state |= candidate
# cover case where units have been removed from the area and then a
# move has reserved locally but not in area
if (
float_compare(
candidate.area_reserved_availability,
candidate.reserved_availability,
precision_rounding=rounding,
)
< 0
):
to_remove = float_round(
candidate.reserved_availability
- candidate.area_reserved_availability,
precision_rounding=rounding,
rounding_method="UP",
)
# treiem les quants d'algun move line
mls = candidate.move_line_ids
removed = 0
for ml in mls:
while removed < to_remove:
ml_removed = min(ml.product_uom_qty, to_remove)
ml.product_uom_qty -= ml_removed
removed += ml_removed
break
move_to_recompute_state._recompute_state()
32 changes: 32 additions & 0 deletions stock_reserve_area/models/stock_move_line.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Copyright 2023 ForgeFlow SL.
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import models


class StockMoveLine(models.Model):
_inherit = "stock.move.line"

def _free_reservation(
self,
product_id,
location_id,
quantity,
lot_id=None,
package_id=None,
owner_id=None,
ml_to_ignore=None,
):
super()._free_reservation(
product_id,
location_id,
quantity,
lot_id=lot_id,
package_id=package_id,
owner_id=owner_id,
ml_to_ignore=ml_to_ignore,
)
reserve_area_ids = self.location_id.reserve_area_ids
for area in reserve_area_ids:
self.env["stock.move"]._free_reservation_area(
self.product_id, area, self.qty_done
)
Loading