diff --git a/setup/stock_reserve_area/odoo/addons/stock_reserve_area b/setup/stock_reserve_area/odoo/addons/stock_reserve_area new file mode 120000 index 000000000000..ae980161940e --- /dev/null +++ b/setup/stock_reserve_area/odoo/addons/stock_reserve_area @@ -0,0 +1 @@ +../../../../stock_reserve_area \ No newline at end of file diff --git a/setup/stock_reserve_area/setup.py b/setup/stock_reserve_area/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/stock_reserve_area/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_reserve_area/__init__.py b/stock_reserve_area/__init__.py new file mode 100644 index 000000000000..cc6b6354ad8f --- /dev/null +++ b/stock_reserve_area/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import post_init_hook diff --git a/stock_reserve_area/__manifest__.py b/stock_reserve_area/__manifest__.py new file mode 100644 index 000000000000..132d99e9c722 --- /dev/null +++ b/stock_reserve_area/__manifest__.py @@ -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", +} diff --git a/stock_reserve_area/hooks.py b/stock_reserve_area/hooks.py new file mode 100644 index 000000000000..78be0f8e9624 --- /dev/null +++ b/stock_reserve_area/hooks.py @@ -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, + } + ) diff --git a/stock_reserve_area/models/__init__.py b/stock_reserve_area/models/__init__.py new file mode 100644 index 000000000000..e7b73ffb9507 --- /dev/null +++ b/stock_reserve_area/models/__init__.py @@ -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 diff --git a/stock_reserve_area/models/stock_location.py b/stock_reserve_area/models/stock_location.py new file mode 100644 index 000000000000..0ea4603a268a --- /dev/null +++ b/stock_reserve_area/models/stock_location.py @@ -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, + } + ) diff --git a/stock_reserve_area/models/stock_move.py b/stock_reserve_area/models/stock_move.py new file mode 100644 index 000000000000..bd6df5b906f3 --- /dev/null +++ b/stock_reserve_area/models/stock_move.py @@ -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() diff --git a/stock_reserve_area/models/stock_move_line.py b/stock_reserve_area/models/stock_move_line.py new file mode 100644 index 000000000000..f90888cc366d --- /dev/null +++ b/stock_reserve_area/models/stock_move_line.py @@ -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 + ) diff --git a/stock_reserve_area/models/stock_move_reserve_area_line.py b/stock_reserve_area/models/stock_move_reserve_area_line.py new file mode 100644 index 000000000000..83c91470268a --- /dev/null +++ b/stock_reserve_area/models/stock_move_reserve_area_line.py @@ -0,0 +1,94 @@ +# Copyright 2023 ForgeFlow SL. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models +from odoo.exceptions import UserError +from odoo.tools import float_is_zero + + +class StockMoveReserveAreaLine(models.Model): + """Will only be created when the move is out of the area""" + + _name = "stock.move.reserve.area.line" + + move_id = fields.Many2one("stock.move") + + picking_id = fields.Many2one("stock.picking", related="move_id.picking_id") + + reserved_availability = fields.Float( + string="Reserved in this Area", + digits="Product Unit of Measure", + default=0.0, + readonly=True, + copy=False, + help="Quantity that has been reserved in the reserve" + " Area of the source location.", + ) + + reserve_area_id = fields.Many2one("stock.reserve.area", ondelete="cascade") + + product_id = fields.Many2one( + "product.product", related="move_id.product_id", store=True + ) + not_reserved_in_child_areas = fields.Boolean( + compute="_compute_not_reserved_in_child_area", + search="_search_not_reserved_in_child_areas", + store=False, + ) + + def _compute_not_reserved_in_child_area(self): + for rec in self: + # TODO: compute correctly, but in reality we don't need to... + rec._compute_not_reserved_in_child_area = True + + def _search_not_reserved_in_child_areas(self, operator, value): + if operator not in ["=", "!="] or not isinstance(value, bool): + raise UserError(_("Operation not supported")) + if operator != "=": + value = not value + self._cr.execute( + """ + SELECT smral1.id, smral1.move_id, smral1.reserve_area_id + FROM stock_move_reserve_area_line AS smral1 + LEFT JOIN stock_reserve_area_rel AS rel + ON smral1.reserve_area_id = rel.stock_reserve_area_1 + LEFT JOIN stock_move_reserve_area_line AS smral2 + ON smral2.move_id = smral1.move_id + AND smral2.reserve_area_id = rel.stock_reserve_area_2 + WHERE coalesce(smral1.reserved_availability,0.0) > 0.0 + AND COALESCE(smral1.reserved_availability, 0.0) > + COALESCE(smral2.reserved_availability, 0.0) + GROUP BY smral1.id, smral1.move_id, smral1.reserve_area_id; + """ + ) + return [ + ("id", "in" if value else "not in", [r[0] for r in self._cr.fetchall()]) + ] + + def _action_area_assign(self): + area_reserved_availability = { + area_line: area_line.reserved_availability for area_line in self + } # reserved in area from this move + for area_line in self: + area_available_quantity = self.env[ + "stock.quant" + ]._get_reserve_area_available_quantity( + area_line.product_id, area_line.reserve_area_id + ) + missing_reserved_uom_quantity = ( + area_line.move_id.product_uom_qty + - area_reserved_availability[area_line] + ) + missing_reserved_quantity = area_line.move_id.product_uom._compute_quantity( + missing_reserved_uom_quantity, + area_line.product_id.uom_id, + rounding_method="HALF-UP", + ) + need = missing_reserved_quantity + if area_available_quantity <= 0: + continue + taken_quantity = min(area_available_quantity, need) + rounding = area_line.product_id.uom_id.rounding + if float_is_zero(taken_quantity, precision_rounding=rounding): + continue + area_line.reserved_availability += taken_quantity diff --git a/stock_reserve_area/models/stock_picking.py b/stock_reserve_area/models/stock_picking.py new file mode 100644 index 000000000000..f759a93a8a6f --- /dev/null +++ b/stock_reserve_area/models/stock_picking.py @@ -0,0 +1,14 @@ +# Copyright 2023 ForgeFlow SL. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class StockLocation(models.Model): + _inherit = "stock.picking" + + reserve_area_line_ids = fields.One2many( + "stock.move.reserve.area.line", + "picking_id", + help="Reserve areas of the source location", + ) diff --git a/stock_reserve_area/models/stock_quant.py b/stock_reserve_area/models/stock_quant.py new file mode 100644 index 000000000000..7ccb9784f654 --- /dev/null +++ b/stock_reserve_area/models/stock_quant.py @@ -0,0 +1,44 @@ +# Copyright 2023 ForgeFlow SL. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.tools import float_compare + + +class StockQuant(models.Model): + _inherit = "stock.quant" + + def _get_reserve_area_available_quantity(self, product_id, reserve_area_id): + self = self.sudo() + quants = self._gather_reserve_area(product_id, reserve_area_id) + rounding = product_id.uom_id.rounding + sum_qty = ( + self.read_group([("id", "in", quants.ids)], ["quantity"], [])[0]["quantity"] + or 0 + ) + sum_area_reserved_qty = ( + self.env["stock.move.reserve.area.line"].read_group( + [ + ("reserve_area_id", "=", reserve_area_id.id), + ("product_id", "=", product_id.id), + ], + ["reserved_availability"], + [], + )[0]["reserved_availability"] + or 0 + ) + available_quantity = float(sum_qty) - float(sum_area_reserved_qty) + return ( + available_quantity + if float_compare(available_quantity, 0.0, precision_rounding=rounding) + >= 0.0 + else 0.0 + ) + + def _gather_reserve_area(self, product_id, reserve_area_id): + self.env["stock.quant"].flush(["available_quantity"]) + quant_ids = [] + for location_id in reserve_area_id.location_ids: + if reserve_area_id.is_location_in_area(location_id): + quant_ids += self._gather(product_id, location_id).ids + return self.browse(quant_ids) diff --git a/stock_reserve_area/models/stock_reserve_area.py b/stock_reserve_area/models/stock_reserve_area.py new file mode 100644 index 000000000000..cfc5cd342d67 --- /dev/null +++ b/stock_reserve_area/models/stock_reserve_area.py @@ -0,0 +1,197 @@ +# Copyright 2023 ForgeFlow SL. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class StockReserveArea(models.Model): + _name = "stock.reserve.area" + + name = fields.Char() + active = fields.Boolean(default=True) + company_id = fields.Many2one( + "res.company", default=lambda self: self.env.company, required=True + ) + + location_ids = fields.Many2many( + "stock.location", + relation="stock_reserve_area_stock_location_rel", + column1="reserve_area_id", + column2="location_id", + help="Selected locations a and their children will belong to the Reserve Area", + domain="""[ + ('usage', 'in', ('internal', 'view')), + ('company_id', '=', company_id) + ]""", + ) + child_area_ids = fields.Many2many( + comodel_name="stock.reserve.area", + relation="stock_reserve_area_rel", + column1="stock_reserve_area_1", + column2="stock_reserve_area_2", + compute="_compute_child_area_ids", + store=True, + ) + + @api.depends("location_ids") + def _compute_child_area_ids(self): + computed_areas = self.env.context.get( + "computed_areas", self.env["stock.reserve.area"] + ) + child_areas = self.env["stock.reserve.area"] + for area in self: + if isinstance(area.id, models.NewId): + continue + location_areas = area.location_ids.mapped("reserve_area_ids") - area + for location_area in location_areas: + if all( + area.is_location_in_area(loc) for loc in location_area.location_ids + ): + # All the locations in the l5ocation area are inside of our area. + # Therefore this is a child area. + child_areas |= location_area + area.child_area_ids = child_areas + computed_areas |= area + if computed_areas: + location_areas = location_areas - computed_areas + location_areas.with_context( + computed_areas=computed_areas + )._compute_child_area_ids() + + @api.constrains("location_ids") + def check_location_ids(self): + # allareas have to be concentric areas. + areas = self.location_ids.mapped( + "reserve_area_ids" + ) # areas that contain this area's locations + for ( + area + ) in areas: # we loop through all areas to verify that they are concentric + loc_areas = ( + area.location_ids.mapped("reserve_area_ids") - area + ) # we check consistency for all areas of each location + for loc_area in loc_areas: + # or all loc_area locations are inside area or all area locations are + # inside loc_area + if not all( + area.is_location_in_area(loc) for loc in loc_area.location_ids + ) and not all( + loc_area.is_location_in_area(loc) for loc in area.location_ids + ): + raise UserError(_("All Areas must be concentric")) + + def is_location_in_area(self, location): + location_in = self.env["stock.location"].search( + [("id", "child_of", self.location_ids.ids), ("id", "=", location.id)] + ) + return bool(location_in) + + @api.onchange("location_ids") + def _onchange_location_ids(self): + # we add all child locations in the reserve area + self.location_ids = self.env["stock.location"].search( + [("id", "child_of", self.location_ids.ids)] + ) + + def update_reserve_area_lines(self, moves, locs_to_add, locs_to_delete): + for move in moves: + reserve_area_line = self.env["stock.move.reserve.area.line"].search( + [("move_id", "=", move.id), ("reserve_area_id", "=", self.id)] + ) + if ( + move._is_out_area(self) + and not reserve_area_line + and ( + move.location_id in locs_to_add + or move.location_dest_id in locs_to_delete + ) + ): + # the move was within the same area but we removed dest location from it + # and now is out move the move didn't impact this area but now the + # source location is inside of it and it's out move + move.reserve_area_ids |= self + move._do_unreserve() + move._action_assign() # will create new reserve_area_line and reserve + elif ( + not move._is_out_area(self) + and reserve_area_line + and ( + move.location_dest_id in locs_to_add + or move.location_id in locs_to_delete + ) + ): + # 1. the move was out of the area but we dest loc is added in area so it + # is not out move anymore. + # 2. the move was out of the area but we remove source location from + # area so this area doesn't apply for reservation. + move.reserve_area_ids -= self + reserve_area_line.unlink() + move._do_unreserve() + move._action_assign() + + @api.model + def create(self, vals): + res = super().create(vals) + moves_impacted = self.env["stock.move"].search( + [ + "|", + ("location_id", "in", res.location_ids.ids), + ("location_dest_id", "in", res.location_ids.ids), + ("state", "in", ("confirmed", "waiting", "partially_available")), + ] + ) + res.update_reserve_area_lines(moves_impacted, res.location_ids, []) + return res + + def write(self, vals): + location_ids = self.location_ids + res = super().write(vals) + if vals.get("location_ids"): + to_write = self.env["stock.location"].browse( + x for x in vals.get("location_ids")[0][2] + ) + to_delete = location_ids - to_write + to_add = to_write - location_ids + moves_impacted = self.env["stock.move"].search( + [ + "|", + ("location_id", "in", to_add.ids + to_delete.ids), + ("location_dest_id", "in", to_add.ids + to_delete.ids), + ("state", "in", ("confirmed", "waiting", "partially_available")), + ] + ) + self.update_reserve_area_lines(moves_impacted, to_add, to_delete) + return res + + def unlink(self): + locations = self.location_ids + super().unlink() + moves_impacted = self.env["stock.move"].search( + [ + ("location_id", "in", locations.ids), + ("state", "in", ("confirmed", "waiting", "partially_available")), + ] + ) + moves_impacted._compute_area_reserved_availability() + + def action_open_reserved_moves(self): + self.ensure_one() + move_reserve_area_lines = self.env["stock.move.reserve.area.line"].search( + [("reserve_area_id", "=", self.id), ("reserved_availability", "!=", 0)] + ) + view_id = self.env.ref("stock_reserve_area.move_reserve_area_line_tree").id + context = dict(self.env.context or {}) + context["search_default_product_id"] = 1 + return { + "name": _("Reserved in Areas lines"), + "type": "ir.actions.act_window", + "view_mode": "tree", + "res_model": "stock.move.reserve.area.line", + "view_id": view_id, + "target": "new", + "search_view_id": self.env.ref( + "stock_reserve_area.move_reserve_area_line_search" + ).id, + "domain": [("id", "in", move_reserve_area_lines.ids)], + "context": context, + } diff --git a/stock_reserve_area/models/stock_warehouse.py b/stock_reserve_area/models/stock_warehouse.py new file mode 100644 index 000000000000..e4537b09e312 --- /dev/null +++ b/stock_reserve_area/models/stock_warehouse.py @@ -0,0 +1,19 @@ +# Copyright 2023 ForgeFlow SL. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class StockWarehouse(models.Model): + _inherit = "stock.warehouse" + + @api.model + def create(self, vals): + warehouse = super().create(vals) + self.env["stock.reserve.area"].sudo().create( + { + "name": warehouse.name, + "location_ids": [(6, 0, [warehouse.view_location_id.id])], + } + ) + return warehouse diff --git a/stock_reserve_area/readme/CONTRIBUTORS.rst b/stock_reserve_area/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000000..3f68171bda40 --- /dev/null +++ b/stock_reserve_area/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `ForgeFlow `_: + + * Maria de Luna diff --git a/stock_reserve_area/readme/DESCRIPTION.rst b/stock_reserve_area/readme/DESCRIPTION.rst new file mode 100644 index 000000000000..de2ed91b3e84 --- /dev/null +++ b/stock_reserve_area/readme/DESCRIPTION.rst @@ -0,0 +1,23 @@ +Allows to create reservation areas to manage stock globally on them. +In pickings that move a product out of the source location area, +before making the reservation in the location, the product needs to be available +and reserved in the area. + +One location can be inside more than one area as long as the areas are concentric. +For example, there can be a Company level reserve area, inside of it an area for WH1 and inside +an area for Stock. Then, WH/Stock location will be inside of these three areas at the same time. + +The field "Area Reserved Availability" of moves, indicates the quantity that the +system has been able to reserve in all areas (the minimum reserved in the moves' areas). + +Use case example: + +You have customer demand in WH, and yet you don't want to necessary reserve the stock for that demand in a particular location upfront. +The delivery orders would place demand on WH/Output for example, where there's no stock. + +Then, the warehouse people can decide to fulfill that demand later by organizing pickings from WH/Stock to WH/Output. + +However, if you try to do that without this module, you cannot guarantee to the customer of a particular sales order that they will get the products they ordered. + +With this module, as soon as the delivery order is created, an area level reservation (typicall area will be the whole warehouse) will take place, regardless if the local reservation has taken place yet. +That way we guarantee that new orders will not 'steal' from the previous ones. diff --git a/stock_reserve_area/readme/USAGE.rst b/stock_reserve_area/readme/USAGE.rst new file mode 100644 index 000000000000..87b2cd6cbd3f --- /dev/null +++ b/stock_reserve_area/readme/USAGE.rst @@ -0,0 +1,7 @@ +To make a stock reservation: + +#. Go to *Inventory > Configuration > Reservation Areas*. +#. Create your desired reservation areas. +#. Each time a picking with a source location inside one area and dest location out of + it, the system will first make a reservation for the products in the area and then + in the location. diff --git a/stock_reserve_area/security/ir.model.access.csv b/stock_reserve_area/security/ir.model.access.csv new file mode 100644 index 000000000000..e90b5b125f02 --- /dev/null +++ b/stock_reserve_area/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_reserve_area_stock_manager,access_stock_reserve_area_stock_manager,model_stock_reserve_area,stock.group_stock_manager,1,1,1,1 +access_stock_reserve_area_stock_user,access_stock_reserve_area_stock_user,model_stock_reserve_area,stock.group_stock_user,1,0,0,0 +access_stock_move_reserve_area_line,access_stock_move_reserve_area_line,model_stock_move_reserve_area_line,base.group_user,1,1,1,1 diff --git a/stock_reserve_area/security/stock_reserve_area_security.xml b/stock_reserve_area/security/stock_reserve_area_security.xml new file mode 100644 index 000000000000..cca495c4214f --- /dev/null +++ b/stock_reserve_area/security/stock_reserve_area_security.xml @@ -0,0 +1,15 @@ + + + + + stock_reserve_area multi-company + + + [('company_id', 'in', company_ids)] + + + diff --git a/stock_reserve_area/tests/__init__.py b/stock_reserve_area/tests/__init__.py new file mode 100644 index 000000000000..0f213c2b3f41 --- /dev/null +++ b/stock_reserve_area/tests/__init__.py @@ -0,0 +1 @@ +from . import test_stock_reserve diff --git a/stock_reserve_area/tests/test_stock_reserve.py b/stock_reserve_area/tests/test_stock_reserve.py new file mode 100644 index 000000000000..e0dc288b4ba6 --- /dev/null +++ b/stock_reserve_area/tests/test_stock_reserve.py @@ -0,0 +1,224 @@ +# Copyright 2023 ForgeFLow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.tests import common + + +class TestStockReserveArea(common.TransactionCase): + def setUp(self): + """ + We will create 4 reserve areas: + - Company level reserve area + - WH1 Reserve Area + - WH1-Stock1 Reserve Area + - WH2 Reserve Area + + """ + super().setUp() + self.warehouse1 = self.env["stock.warehouse"].create( + {"name": "Test warehouse 1", "code": "TWH1"} + ) + self.warehouse2 = self.env["stock.warehouse"].create( + {"name": "Test warehouse 2", "code": "TWH2"} + ) + self.wh1_stock1 = self.warehouse1.lot_stock_id + self.wh1_stock2 = self.env["stock.location"].create( + { + "name": "Stock2", + "location_id": self.wh1_stock1.location_id.id, + } + ) + self.wh2_stock1 = self.warehouse2.lot_stock_id + self.customer_location = self.env.ref("stock.stock_location_customers") + + self.product = self.env["product.product"].create( + {"name": "Test Product", "type": "product"} + ) + + self.env["stock.quant"].create( + { + "product_id": self.product.id, + "location_id": self.wh1_stock1.id, + "quantity": 10.0, + } + ) + + self.reserve_area1 = self.env["stock.reserve.area"].search( + [("location_ids", "in", self.warehouse1.view_location_id.id)] + ) + self.reserve_area2 = self.env["stock.reserve.area"].search( + [("location_ids", "in", self.warehouse2.view_location_id.id)] + ) + + self.stock_reserve_area_wh1_stck1 = self.env["stock.reserve.area"].create( + { + "name": "WH1-Stock1", + "location_ids": [(6, 0, [self.wh1_stock1.id])], + "company_id": self.env.user.company_id.id, + } + ) + + all_locations = ( + self.reserve_area1.location_ids + self.reserve_area2.location_ids + ) + + self.reserve_area_company = self.env["stock.reserve.area"].create( + { + "name": "Company", + "location_ids": [(6, 0, all_locations.ids)], + "company_id": self.env.user.company_id.id, + } + ) + self.picking_type_out = self.env.ref("stock.picking_type_out") + self.picking_type_internal = self.env.ref("stock.picking_type_internal") + + def test_reservation_picking_area1(self): + """We create a picking from WH1-Stock1 to WH1-Stock2. + The only reserve area impacted should be WH1-Stock1 and since there is stock of + it in the source location + it should be reserved in the Area and locally. + """ + picking_internal_area = self.env["stock.picking"].create( + { + "location_id": self.wh1_stock1.id, + "location_dest_id": self.wh1_stock2.id, + "picking_type_id": self.picking_type_internal.id, + "company_id": self.env.company.id, + } + ) + move = self.env["stock.move"].create( + { + "name": self.product.name, + "product_id": self.product.id, + "product_uom_qty": 1, + "product_uom": self.product.uom_id.id, + "picking_id": picking_internal_area.id, + "location_id": self.wh1_stock1.id, + "location_dest_id": self.wh1_stock2.id, + } + ) + picking_internal_area.action_confirm() + picking_internal_area.action_assign() + self.assertEqual(len(move.reserve_area_line_ids), 1) + self.assertEqual(move.reserve_area_line_ids.reserve_area_id.name, "WH1-Stock1") + self.assertEqual( + move.reserve_area_line_ids.reserved_availability, + 1, + "1 unit should have been " + "reserved in the WH1-Stock1 Reserve Area" + "for this product.", + ) + self.assertEqual( + move.area_reserved_availability, + 1, + "1 units should have been " + "reserved in the move for all Areas" + "for this product.", + ) + self.assertEqual( + move.reserved_availability, + 1, + "1 units should have been " + "reserved in the source location move" + "for this product.", + ) + + def test_reservation_picking_out_area2(self): + """We create a picking where the product will go out of all reserve areas except + WH2. + There is stock of it in the source location. + The product should be reserved in all impacted areas and in the location.""" + picking_out_area = self.env["stock.picking"].create( + { + "location_id": self.wh1_stock1.id, + "location_dest_id": self.customer_location.id, + "picking_type_id": self.picking_type_out.id, + "company_id": self.env.company.id, + } + ) + move = self.env["stock.move"].create( + { + "name": self.product.name, + "product_id": self.product.id, + "product_uom_qty": 1, + "product_uom": self.product.uom_id.id, + "picking_id": picking_out_area.id, + "location_id": self.wh1_stock1.id, + "location_dest_id": self.customer_location.id, + } + ) + picking_out_area.action_confirm() + picking_out_area.action_assign() + self.assertEqual(len(move.reserve_area_line_ids), 3) + self.assertEqual( + move.area_reserved_availability, + 1, + "One unit should have been " + "reserved in all Reserve Areas" + "for this product.", + ) + self.assertEqual( + move.reserved_availability, + 1, + "One unit should have been " "reserved for this product in TWH1/Stock.", + ) + for sml in picking_out_area.move_lines.mapped("move_line_ids"): + sml.qty_done = sml.product_qty + picking_out_area._action_done() + self.assertEqual( + move.area_reserved_availability, + 0, + "There shouldn't be any reserved units in the area for this move.", + ) + + def test_reservation_picking_out_area3(self): + """We create a picking where the product will go out of WH2 and Company reserve + areas. + There is no stock of it in the source location. + The product should be reserved only in the Company area but not in the others or + in the source location.""" + picking_out_area = self.env["stock.picking"].create( + { + "location_id": self.wh2_stock1.id, + "location_dest_id": self.customer_location.id, + "picking_type_id": self.picking_type_out.id, + "company_id": self.env.company.id, + } + ) + move = self.env["stock.move"].create( + { + "name": self.product.name, + "product_id": self.product.id, + "product_uom_qty": 1, + "product_uom": self.product.uom_id.id, + "picking_id": picking_out_area.id, + "location_id": self.wh2_stock1.id, + "location_dest_id": self.customer_location.id, + } + ) + picking_out_area.action_confirm() + picking_out_area.action_assign() + self.assertEqual(len(move.reserve_area_line_ids), 2) + self.assertEqual( + move.area_reserved_availability, + 0, + "0 units should have been " + "reserved in all Reserve Areas" + "for this product.", + ) + self.assertEqual( + move.reserved_availability, + 0, + "0 units should have been reserved for this product in TWH2/Stock.", + ) + for area_line in move.reserve_area_line_ids: + if area_line.reserve_area_id.name == "Company": + self.assertEqual(area_line.reserved_availability, 1) + else: + self.assertEqual(area_line.reserved_availability, 0) + + picking_out_area.do_unreserve() + self.assertEqual( + move.area_reserved_availability, + 0, + "There shouldn't be any reserved units in the area for this move.", + ) diff --git a/stock_reserve_area/views/stock_location_views.xml b/stock_reserve_area/views/stock_location_views.xml new file mode 100644 index 000000000000..4f2cae3814fe --- /dev/null +++ b/stock_reserve_area/views/stock_location_views.xml @@ -0,0 +1,13 @@ + + + + stock.location.form + stock.location + + + + + + + + diff --git a/stock_reserve_area/views/stock_move_reserve_area_line_views.xml b/stock_reserve_area/views/stock_move_reserve_area_line_views.xml new file mode 100644 index 000000000000..e7dbd4d77a65 --- /dev/null +++ b/stock_reserve_area/views/stock_move_reserve_area_line_views.xml @@ -0,0 +1,80 @@ + + + + stock.move.reserve.area.line.form + stock.move.reserve.area.line + +
+ +
+ + + + + + + + + + + + + stock.move.reserve.area.line.tree + stock.move.reserve.area.line + + + + + + + + + + + + + stock.move.reserve.area.line.search + stock.move.reserve.area.line + + + + + + + + + + + + + + + Stock Move Reserve Area Lines + stock.move.reserve.area.line + ir.actions.act_window + + + + + + diff --git a/stock_reserve_area/views/stock_move_views.xml b/stock_reserve_area/views/stock_move_views.xml new file mode 100644 index 000000000000..9628ee879b05 --- /dev/null +++ b/stock_reserve_area/views/stock_move_views.xml @@ -0,0 +1,82 @@ + + + + stock.move.operations.form + stock.move + + + + + + + + + + stock.move.operations.reserve.area.form + stock.move + + + + + + + + + + + + + + + + + + reserve.area.stock.picking.move.tree + stock.move + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/stock_reserve_area/views/stock_picking_views.xml b/stock_reserve_area/views/stock_picking_views.xml new file mode 100644 index 000000000000..05d571b7f5a4 --- /dev/null +++ b/stock_reserve_area/views/stock_picking_views.xml @@ -0,0 +1,49 @@ + + + + stock.picking.form + stock.picking + + + + + + + + + + + + + + + + + + + + diff --git a/stock_reserve_area/views/stock_reserve_area_views.xml b/stock_reserve_area/views/stock_reserve_area_views.xml new file mode 100644 index 000000000000..687396e5f0a2 --- /dev/null +++ b/stock_reserve_area/views/stock_reserve_area_views.xml @@ -0,0 +1,99 @@ + + + + stock.reserve.area + stock.reserve.area + +
+ +
+ +
+ +
+
+
+
+ + + stock.reserve.area.tree + stock.reserve.area + + + + + + + + + + + stock.reserve.area.search + stock.reserve.area + + + + + + + + + + Reserve Areas + stock.reserve.area + ir.actions.act_window + + +

+ Define a new Reserve Area +

+
+
+ + + +